fix(studio): playhead keyframe edits keep their ease, undo names its edit and never waits on videos (#4889)

* fix(studio): an edit on a GSAP-animated layer writes its value at the playhead into the file's tween

* fix(studio): undo pressed while an edit saves undoes that edit, not one started after the key

* fix(studio): an edit stays saved when the history reply after it cannot be read

* feat(studio): export ColorField and GradientField; a session style commit takes a map

* refactor(studio): name the map form instead of commenting it

* feat(studio): a host can move layers by exact film px, one undo per call

handleDomGroupMoveBy(moves) on useDomEditSession and DomEditValue moves each
selection by its delta in composition px, for Desktop's align and distribute.

It reuses the group drag's own path: a drag member per element (press-time
route, measured offset map), the delta carried to screen px through the
preview frames, then handleGsapAwareGroupPathOffsetCommit, so one shared
coalesce key gives one undo entry and GSAP members take the GSAP write.
A member that can't move refuses the call before anything is written; the
refusal rejects with its reason and shows no Studio toast (the group commit
takes refusalToast: false; telemetry stays). Zero deltas are skipped.

Tests: a pre-offset element and one under a half-scale parent move by exactly
{10,0} and {0,-5} under one group key; a half-scale parent with a 30 deg
rotated ancestor lands within the 0.001 px a translate is written with; a
GSAP member goes through the GSAP write; three refusal kinds write nothing,
leave the boxes where they were, show no toast and leave no gesture mark,
while a canvas group drag still toasts; the session hands the call to the
group commit with refusalToast: false. Each mutation (no frame mapping, no
zero skip, no restore, no teardown, no capability check, toast on refusal)
fails at least one of them.

* fix(studio): an outside change during a drag waits for the drop to show

While a gesture mark is live in the preview, refreshPlayer takes the shadow load instead of
swapping scenes in place. A swap replaced the node under the pointer mid-drag; the shadow load
is then held by the existing gesture hold and, if the drop saved, reloaded fresh by the stale
rule, so this adds no second "released" signal.

Test: an outside change during a live drag on a preview that can swap scenes leaves the dragged
node in place, shows nothing under the pointer, and after the drop loads fresh with the dropped
position. It fails on the parent without the condition ("swapped under the pointer").

Cost case, measured in a real headless preview on the edit-accuracy bench's per-frame teleport
check. Every GSAP instant patch was forced to miss and fall back to reloadPreview during its own
commit; move and rotate of a held element (gsap.set and tl.set holds), px and xPercent
placement, r0 and r30, root and nested, at 100%:
- The gesture mark was live when the fallback asked for the reload, but had always ended by the
  time refreshPlayer ran (refreshKey is consumed in an effect after the commit settles). So the
  new condition never fired there: root previews offer no scene swap and load shadow on both
  builds, and nested previews swap on both builds.
- Worst post-release frame offset, 32 cases on this build and 32 on the parent: 0.00 px over
  76-106 frames each (release to settle). No frame was off by more than 0.5 px.
- Sensitivity: a 3 px nudge injected 50 ms after the fallback reads back as 3.00 px.

* fix(studio): an edit no longer reopens the live-updates stream, and thumbnails ask one at a time

* fix(studio): an undo no longer waits behind paused preview videos for a connection

* feat(studio): a host whose API needs same-origin cookies can have Studio send them

* docs(studio-server): a host on same-origin cookies sets the Studio credentials opt-out

* test(studio): an undo is not held up by the preview's paused videos

* fix(studio): a resize inside one gesture writes a mixed tween once, in place, never beside it

* chore(studio): keep the carried fixes inside the comment ratchet; telemetry posts through studioApiFetch

* test(studio): a drag on a mixed tween is one write under the gesture key

* fix(studio): freeze-frame, loudness and peak-map requests go through studioApiFetch too

* fix(studio): an undo with no project open reloads in full instead of skipping nested files

* refactor(studio): drop the soft reload's reparse seeding, the held-element sweep covers it

* test(studio): keep the undo-restore browser test off the network and inside its timeout

* test(studio): nested composition reads expect the cookie-free request studioApiFetch sends

* fix(studio): an edit at the playhead rewrites its tween in place, so later tweens keep their times

* fix(studio): a rotate on a tween that turns another axis holds its angle and never turns early

* fix(studio): plan every group member before the first write so one refusal moves none

A group drag or nudge preflighted each member against the move refusal rule only. The value-at-playhead planner ran later, in the commit loop, after earlier members were already flushed to disk, so a tween with extras, eased keyframes or an unknown ease left the group half moved.

The group preflight now runs the commit's own route and keyframe plan for every member with its real offset, so any refusal refuses the whole group with nothing written and one toast attributed to the refusing member. The plan is not reused in the commit: earlier members' writes change the file under it, so each member re-plans against the fresh tween, from the same planValueEdit.

* chore(studio): the fetch lint rule also catches window.fetch, globalThis.fetch and self.fetch

* docs(studio-server): set the credentials flag before Studio loads; a mounting host injects it

* test(studio): the undo socket test needs six pinned videos under Vite and checks each undo restores

* fix(studio): Studio leaves cookies out of its requests only when served from loopback

* fix(studio): undo takes back the first edit, and the first save never waits on media

* fix(studio): undo of a move whose save failed stays undone and steps back no older edit

* fix(studio): a style set by its JavaScript name saves under its CSS name

* test(studio): the undo socket test holds round 0 to a full restore too

* fix(studio): the host revert keeps returning what shows the edit again

* fix(studio): an entrance rewritten at the playhead still shows its start state before it starts

* fix(studio): undo takes back the edit pending at the key even after a later edit claims

* chore(studio): lift the late-write quarantine now that undo never waits on videos; format

* test(studio): pin an entrance's start render on the written tween, not a happy-dom GSAP run

* revert(studio): leave entrances as on main, immediateRender does not render a keyframed start

This reverts commit ee9d130d3c and its written-vars test.

* revert(studio): undo goes back to the newest claim after the key, as on main

This reverts commit 5b9009a823.

* revert(studio): first-undo history opening moves to the next PR

This reverts commit 4e1e75d7f4.

* revert(studio): the undo socket test exempts round 0 again until first-undo lands

This reverts commit 1f8f97047c.

* test(studio): bank the edit accuracy cases this PR makes pass

* refactor(studio): share one project file fetch between the plain and optional reads

* test(studio): share the WebMCP host shim between the browser gates

* test(studio-server): share one stub adapter across the route tests

* refactor(studio): type the preview slot props from the NLE context

* test(studio): share the setup the new editing tests copied between files

* refactor(studio): split playhead tween picking and undo path merging into small tested helpers

* test(studio): pin when a resize at the playhead also writes the anchor move

* test(parsers): pin rewriting a tween in place as keyframes

* chore(studio): cut comments the new helper names already say

* fix(studio): composition thumbnails keep two fetches in flight, as the document lifetime fix expects

* fix(studio): a playhead edit parses a tween not yet played and matches only its own tween

* fix(parsers): a new position argument goes after the last argument, so trailing commas stay valid

* fix(studio): a second undo pressed during one save steps back to the edit before it

* test(studio): pin the slot an aborted thumbnail frees and the shared tween refusal

* refactor(studio): split the unplayed-tween parse into small helpers and name the claim release

* fix(studio): a playhead edit keeps the tween's default ease, delay timing and loop settings

* fix(studio): a group move refuses layers animated in two files and plans at the real drop

* style(studio): format two test files

* refactor(studio): check a group's files inside the preflight helper

* refactor(studio): move the group preflight checks out of the editing hook, under 600 lines

* fix(studio): read an unreached tween without redrawing other layers, and compare write files

* test(studio): use the shared selection helper in main's keyframe-at-time telemetry test

* chore(studio): fix a comment that read as a repo path and cut one added comment

* fix(studio): read a later tween's start as playback does, and count every keyframe add

* fix(studio): two undo presses during one save step back two edits, not one twice

* fix(studio): an undo that succeeds by any route retires the claim on the edit it undid

* fix(studio): a claim is never handed back to an edit another undo has already undone

* fix(studio): an undo picks its claimed edit at the press and skips it once undone

* fix(studio): a press refuses, as main did, once its edit is undone and a newer edit exists

* fix(studio): an unplayed keyframe tween clears every keyframe's channels before it is read

* fix(studio): reading an unplayed tween puts back every transform value its clear removed

* fix(studio): reading an unplayed tween restores each layer's whole style and GSAP state

* fix(studio): reading an unplayed tween skips plain-object targets and restores attributes

* fix(studio): shared tween-target filter uses GSAP's own styled-element test

* fix(studio): match a parsed tween on any target, and check a later keyframe channel's start

* docs(studio): say the unplayed-tween read restores styled elements only

* test(studio): a drag on a fromTo start shows the dropped x through the runtime seek
This commit is contained in:
Miguel Ángel
2026-10-03 13:10:54 -07:00
committed by GitHub
parent 660674f469
commit 7dea1e581f
152 changed files with 6316 additions and 3196 deletions
+25
View File
@@ -850,6 +850,8 @@ jobs:
packages/studio/data/projects/fade-handles
cp -R packages/studio/tests/e2e/fixtures/inline-text-caret \
packages/studio/data/projects/inline-text-caret
cp -R packages/studio/tests/e2e/fixtures/undo-socket-pool \
packages/studio/data/projects/undo-socket-pool
- name: Read the base branch's work-count ceilings
# A ceiling may not disappear, or rise without a new reason, against the branch this PR merges into.
if: github.event_name == 'pull_request'
@@ -1000,6 +1002,28 @@ jobs:
STUDIO_URL="http://localhost:5317/#project/inline-text-caret" \
EVIDENCE_DIR=/tmp/inline-text-caret \
node packages/studio/tests/e2e/inline-text-caret.mjs
- name: Install ffmpeg for the paused-video fixture
if: success() || failure()
uses: ./.github/actions/install-ffmpeg-linux
- name: Undo while paused videos hold the preview's sockets
# Counts the undo's wait for a socket, not its total time: unpinned it is 0 ms.
if: success() || failure()
run: |
set -euo pipefail
NODE_ENV=production bun run --cwd packages/studio dev -- --port 5318 --strictPort &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
for i in $(seq 1 60); do
if curl -sf "http://localhost:5318/" >/dev/null 2>&1; then break; fi
sleep 1
done
if ! curl -sf "http://localhost:5318/" >/dev/null 2>&1; then
echo "FAIL: studio dev server did not start on port 5318"
exit 1
fi
STUDIO_URL="http://localhost:5318/#project/undo-socket-pool" \
STUDIO_PROJECT_DIR="$PWD/packages/studio/data/projects/undo-socket-pool" \
node packages/studio/tests/e2e/undo-socket-pool.mjs | tee /tmp/undo-socket-pool.json
- name: Upload gate evidence
# The gate's whole output is machine-readable evidence, and a red run is
# exactly when someone needs to read it. Keep it on failure too.
@@ -1011,6 +1035,7 @@ jobs:
/tmp/timeline-gate-*.json
/tmp/studio-open-counts.json
/tmp/inline-text-caret/*.png
/tmp/undo-socket-pool.json
if-no-files-found: error
# Manual editing accuracy in the built Studio, gated against the base branch's baseline.json.
+39
View File
@@ -45,6 +45,45 @@
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
},
{
"files": ["packages/studio/src/**/*.ts", "packages/studio/src/**/*.tsx"],
"excludeFiles": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*TestUtils.ts",
"**/*TestUtils.tsx",
"**/*TestHarness.ts",
"**/*TestHarness.tsx",
"packages/studio/src/utils/studioApiFetch.ts"
],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "fetch",
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
}
],
"no-restricted-properties": [
"error",
{
"object": "window",
"property": "fetch",
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
},
{
"object": "globalThis",
"property": "fetch",
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
},
{
"object": "self",
"property": "fetch",
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
}
]
}
}
]
}
+12
View File
@@ -35,6 +35,18 @@ export function mountStudioApi(
}
```
When it is served from loopback (`localhost`, `127.0.0.1` or `[::1]`), Studio
sends its API requests without cookies, so they don't queue behind the
preview's media. On any other origin it sends them as usual, so remote and
proxied setups (Codespaces, tunnels, an access proxy) keep their cookies by
default. A loopback host that authenticates its mounted API with same-origin
cookies sets `VITE_STUDIO_API_SAME_ORIGIN_CREDENTIALS=true`, at build time or at
runtime in `window.__HF_STUDIO_ENV__`; it costs that host the connection
speedup.
Studio reads the flag once, when its modules load, so set
`window.__HF_STUDIO_ENV__` before loading Studio; the CLI injects it, but a host
that mounts the API itself must inject it too.
A `StudioApiAdapter` supplies:
- project listing and resolution;
+1 -1
View File
@@ -1903,7 +1903,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
tweenCallToAnimation(call, scope, script, identifierBindings),
);
applyTimelineDefaults(rawAnims, detection.defaults);
resolveTimelinePositions(rawAnims);
resolveTimelinePositions(rawAnims, collectAddLabelDefs(ast, ref, scope, calls));
const animations = assignStableIds(rawAnims);
const located = calls.map((call, i) => ({
id: animations[i]!.id,
@@ -13,6 +13,7 @@ import {
copyAnimationsInScript,
removeAnimationFromScript,
removeKeyframeFromScript,
replaceTweenWithKeyframesInScript,
updateAnimationInScript,
updateKeyframeInScript,
} from "./gsapWriterAcorn.js";
@@ -535,3 +536,60 @@ tl.from("#goodbye", { opacity: 0 }, 1);`;
expect(copyAnimationsInScript(script, "#tag", "#tag-2", 3)).toBe(script);
});
});
describe("replaceTweenWithKeyframesInScript", () => {
const script = `var tl = gsap.timeline({ paused: true });
tl.fromTo("#a", { x: 0 }, { x: 100, duration: 1, delay: 0.5, onStart: go }, 1);
tl.set("#b", { x: 5 }, 0);`;
const tweenOn = (selector: string) =>
parseGsapScriptAcorn(script).animations.find((a) => a.targetSelector === selector)!;
const edit = (position: number, extra = {}) => ({
targetSelector: "#a",
position,
duration: 2,
keyframes: [
{ percentage: 100, properties: { x: 100 } },
{ percentage: 0, properties: { x: 0 } },
],
...extra,
});
const rewritten = (out: string | null) => out?.split("\n")[1];
it("leaves a tween it cannot find, or a set(), to the caller", () => {
expect(replaceTweenWithKeyframesInScript(script, "#missing", edit(1))).toBeNull();
expect(replaceTweenWithKeyframesInScript(script, tweenOn("#b").id, edit(0))).toBeNull();
});
it("rewrites a tween that stays put as to(), keeping its position, delay and callbacks", () => {
const { id, resolvedStart } = tweenOn("#a");
expect(rewritten(replaceTweenWithKeyframesInScript(script, id, edit(resolvedStart!)))).toBe(
'tl.to("#a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, delay: 0.5, onStart: go, duration: 2 }, 1);',
);
});
it("moves a retargeted tween to the new position without its delay, with the edit's ease", () => {
const out = replaceTweenWithKeyframesInScript(
script,
tweenOn("#a").id,
edit(3, { targetSelector: "#c", ease: "none", easeEach: "power1.in" }),
);
expect(rewritten(out)).toBe(
'tl.to("#c", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 }, easeEach: "power1.in" }, onStart: go, duration: 2, ease: "none" }, 3);',
);
});
});
describe("position argument", () => {
it("is added after the last argument, so a call with a trailing comma stays valid", () => {
const script = `var tl = gsap.timeline({ paused: true });
tl.to(
"#a",
{ x: 100, duration: 1 },
);
window.__timelines["t"] = tl;`;
const id = parseGsapScriptAcorn(script).animations[0]!.id;
const result = updateAnimationInScript(script, id, { position: 0.5 });
expect(() => new Function("gsap", "window", result)).not.toThrow();
expect(parseGsapScriptAcorn(result).animations[0]!.position).toBe(0.5);
});
});
+50 -1
View File
@@ -443,7 +443,8 @@ function overwritePosition(ms: MagicString, call: TweenCallInfo, position: numbe
if (call.positionArg) {
ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position));
} else {
ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`);
const last = call.node.arguments.at(-1);
ms.appendLeft(last ? last.end : call.node.end - 1, `, ${valueToCode(position)}`);
}
}
@@ -1790,6 +1791,54 @@ export function materializeKeyframesFromScript(
return ms.toString();
}
/**
* Rewrites a tween as `to()` with these keyframes where it stands: the call keeps its place in the
* script and its position argument, so a tween placed after it ('>', '<', '+=', none) stays put.
*/
export function replaceTweenWithKeyframesInScript(
script: string,
animationId: string,
edit: {
targetSelector: string;
position: number;
duration: number;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>;
ease?: string;
easeEach?: string;
},
): string | null {
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((l) => l.id === animationId);
const call = target?.call;
if (!target || call?.varsArg?.type !== "ObjectExpression") return null;
const { animation } = target;
if (animation.method !== "to" && animation.method !== "from" && animation.method !== "fromTo")
return null;
const ms = new MagicString(script);
const start = animation.resolvedStart ?? animation.position;
const moved = typeof start !== "number" || Math.abs(start - edit.position) > 5e-4;
const kept = preservedVarsEntries(call.varsArg, script).filter(
(e) => !/^\s*duration\s*:/.test(e) && !(moved && /^\s*delay\s*:/.test(e)),
);
const sorted = [...edit.keyframes].sort((a, b) => a.percentage - b.percentage);
const parts = [`keyframes: ${buildKeyframeObjectCode(sorted, edit.easeEach)}`, ...kept];
parts.push(`duration: ${valueToCode(edit.duration)}`);
if (edit.ease) parts.push(`ease: ${JSON.stringify(edit.ease)}`);
if (animation.hasUnresolvedSelector || animation.targetSelector !== edit.targetSelector) {
const selectorArg = call.node.arguments[0];
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(edit.targetSelector));
}
convertMethodToTo(ms, animation, call, call.varsArg);
overwriteVarsArg(ms, call, `{ ${parts.join(", ")} }`);
if (moved) overwritePosition(ms, call, edit.position);
return ms.toString();
}
// ── Add animation with keyframes ──────────────────────────────────────────────
/** Insert a new keyframed `to()` call and return the new animation ID. */
@@ -14,6 +14,7 @@ import { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fileContentVersion, identifyFileWrite } from "../helpers/fileVersion";
import { registerFileRoutes } from "./files";
import { stubAdapter } from "./stubAdapter.test-helpers";
const hooks = vi.hoisted(() => ({
transforming: undefined as (() => void) | undefined,
@@ -76,15 +77,7 @@ function project() {
const path = join(dir, "index.html");
writeFileSync(path, ORIGINAL);
const app = new Hono();
registerFileRoutes(app, {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({ id: "job", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }),
});
registerFileRoutes(app, stubAdapter(dir));
const post = (route: string, body: unknown) =>
app.request(`http://localhost/projects/demo/file-mutations/${route}`, {
method: "POST",
@@ -0,0 +1,146 @@
// @vitest-environment node
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Hono } from "hono";
import { afterEach, describe, expect, it } from "vitest";
import { registerFileRoutes } from "./files";
import { stubAdapter } from "./stubAdapter.test-helpers";
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
interface Anim {
id: string;
targetSelector: string;
resolvedStart?: number;
method: string;
}
const notHold = (a: Anim) => a.method !== "set";
function app(script: string): Hono {
const dir = mkdtempSync(join(tmpdir(), "hf-replace-in-place-"));
dirs.push(dir);
const html = `<!DOCTYPE html><html><body data-duration="10">
<div id="a"></div><div id="b"></div><div id="c"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline({ paused: true });
${script}
window.__timelines = { main: tl };
</script>
</body></html>`;
writeFileSync(join(dir, "comp.html"), html);
const hono = new Hono();
registerFileRoutes(hono, stubAdapter(dir));
return hono;
}
async function animations(hono: Hono): Promise<Anim[]> {
const res = await hono.request("http://localhost/projects/demo/gsap-animations/comp.html");
return ((await res.json()) as { animations: Anim[] }).animations;
}
// The script's calls, without the hold sets the server keeps before a first position keyframe.
const calls = (code: string) =>
code.split("\n").filter((line) => line.startsWith("tl.") && !line.includes("hf-hold"));
/** A move on `selector` at the playhead, sent the way the playhead writer sends it. */
async function moveAtPlayhead(script: string, selector: string) {
const hono = app(script);
const before = await animations(hono);
const edited = before.find((a) => a.targetSelector === selector)!;
const res = await hono.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "replace-with-keyframes",
animationId: edited.id,
targetSelector: selector,
position: edited.resolvedStart,
duration: 1,
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 40 } },
{ percentage: 100, properties: { x: 100 } },
],
easeEach: "none",
}),
});
const { after: html } = (await res.json()) as { after: string };
const after = await animations(hono);
const starts = (list: Anim[]) => list.map((a) => [a.targetSelector, a.resolvedStart]);
return { html, starts: starts(after.filter(notHold)), was: starts(before) };
}
describe("an edit at the playhead rewrites its tween where it stands", () => {
it.each([
[
"no position argument",
`tl.to("#a", { x: 100, duration: 1 });\ntl.to("#b", { y: 50, duration: 1 });`,
],
[
"'<' and '+='",
`tl.to("#a", { x: 100, duration: 1 }, 1);\ntl.to("#b", { y: 50, duration: 1 }, "<");\ntl.to("#c", { y: 9, duration: 1 }, "+=0.5");`,
],
[
"'>'",
`tl.to("#a", { x: 100, duration: 1 }, 0.5);\ntl.to("#b", { y: 50, duration: 1 }, ">");`,
],
[
"a label",
`tl.addLabel("intro", 2);\ntl.to("#a", { x: 100, duration: 1 }, "intro");\ntl.to("#b", { y: 50, duration: 1 }, "intro+=0.5");`,
],
[
"a from() entrance",
`tl.from("#a", { x: -60, duration: 1 });\ntl.to("#b", { y: 50, duration: 1 });`,
],
])("leaves every other tween's source and start alone after %s", async (_, script) => {
const { html, starts, was } = await moveAtPlayhead(script, "#a");
expect(starts).toEqual(was);
const others = calls(script).filter((line) => !line.includes('"#a"'));
expect(calls(html).filter((line) => !line.includes('"#a"'))).toEqual(others);
expect(calls(html).findIndex((line) => line.includes('"#a"'))).toBe(
calls(script).findIndex((line) => line.includes('"#a"')),
);
});
it("keeps a chained call's next link in place", async () => {
const script = `tl.to("#a", { x: 100, duration: 1 }).to("#b", { y: 50, duration: 1 });`;
const { html, starts, was } = await moveAtPlayhead(script, "#a");
expect(starts).toEqual(was);
expect(calls(html)).toHaveLength(1);
expect(calls(html)[0]).toMatch(
/^tl\.to\("#a", \{ keyframes: .*\}\)\.to\("#b", \{ y: 50, duration: 1 \}\);$/,
);
});
it.each([
[
"a relative one",
`tl.to("#b", { y: 50, duration: 1 }, 0);\ntl.to("#a", { x: 100, duration: 1 }, "<");`,
`"<");`,
],
[
"a label",
`tl.addLabel("intro", 2);\ntl.to("#a", { x: 100, duration: 1 }, "intro");`,
`"intro");`,
],
[
"the last tween's number",
`tl.to("#b", { y: 50, duration: 1 }, 0);\ntl.to("#a", { x: 100, duration: 1 }, 1);`,
`}, 1);`,
],
])("keeps the edited tween's own position argument when it is %s", async (_, script, tail) => {
const { html, starts, was } = await moveAtPlayhead(script, "#a");
expect(starts).toEqual(was);
const edited = calls(html).find((line) => line.includes('"#a"'))!;
expect(edited.endsWith(tail)).toBe(true);
expect(calls(html).indexOf(edited)).toBe(calls(script).findIndex((l) => l.includes('"#a"')));
});
});
+22 -22
View File
@@ -71,6 +71,7 @@ import {
addMotionPathToScript,
removeArcPathFromScript,
addAnimationWithKeyframesToScript,
replaceTweenWithKeyframesInScript,
splitAnimationsInScript,
splitIntoPropertyGroupsFromScript,
shiftPositionsInScript,
@@ -1299,6 +1300,25 @@ export type GsapMutationRequest =
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
function replaceInPlaceUnlessSetOrUnknown(
scriptText: string,
body: Extract<GsapMutationRequest, { type: "replace-with-keyframes" }>,
): string {
const edit = { ...body, easeEach: resolveReplacementEaseEach(scriptText, body) };
const inPlace = replaceTweenWithKeyframesInScript(scriptText, body.animationId, edit);
if (inPlace !== null) return inPlace;
const script = removeAnimationFromScript(scriptText, body.animationId);
return addAnimationWithKeyframesToScript(
script,
body.targetSelector,
body.position,
body.duration,
body.keyframes,
body.ease,
edit.easeEach,
).script;
}
function resolveReplacementEaseEach(
scriptText: string,
request: { animationId: string; easeEach?: string },
@@ -1779,17 +1799,7 @@ function executeGsapMutationAcorn(
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
stripStudioEditsFromTarget(block.document, body.targetSelector);
}
const script = removeAnimationFromScript(block.scriptText, body.animationId);
const added = addAnimationWithKeyframesToScript(
script,
body.targetSelector,
body.position,
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
return replaceInPlaceUnlessSetOrUnknown(block.scriptText, body);
}
case "split-animations": {
if (
@@ -2151,17 +2161,7 @@ async function executeGsapMutationRecast(
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
stripStudioEditsFromTarget(block.document, body.targetSelector);
}
const script = removeAnimationFromScript(block.scriptText, body.animationId);
const added = addAnimationWithKeyframesToScript(
script,
body.targetSelector,
body.position,
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
return replaceInPlaceUnlessSetOrUnknown(block.scriptText, body);
}
case "split-animations": {
if (
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { registerFreezeFrameRoutes, type FrameExtractor } from "./freezeFrame";
import { fileContentVersion } from "../helpers/fileVersion";
import type { StudioApiAdapter } from "../types";
import { stubAdapter } from "./stubAdapter.test-helpers";
const tempDirs: string[] = [];
afterEach(() => {
@@ -25,17 +25,8 @@ function setup(
tempDirs.push(dir);
mkdirSync(dirname(join(dir, file.path)), { recursive: true });
writeFileSync(join(dir, file.path), file.html);
const adapter: StudioApiAdapter = {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({ id: "j", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }),
};
const app = new Hono();
registerFreezeFrameRoutes(app, adapter, extract, stillToken);
registerFreezeFrameRoutes(app, stubAdapter(dir), extract, stillToken);
const post = (body: unknown) =>
app.request("http://localhost/projects/demo/file-mutations/freeze-frame", {
method: "POST",
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerPeakRoutes } from "./peaks";
import type { StudioApiAdapter } from "../types";
import { stubAdapter } from "./stubAdapter.test-helpers";
const dirs: string[] = [];
afterEach(() => {
@@ -15,17 +15,8 @@ function setup(decode: (path: string) => Promise<number[]>) {
const dir = mkdtempSync(join(tmpdir(), "hf-peaks-route-"));
dirs.push(dir);
writeFileSync(join(dir, "talk.mp4"), "video");
const adapter: StudioApiAdapter = {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({ id: "j", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }),
};
const app = new Hono();
registerPeakRoutes(app, adapter, decode);
registerPeakRoutes(app, stubAdapter(dir), decode);
return app;
}
@@ -0,0 +1,14 @@
import type { StudioApiAdapter } from "../types";
/** An adapter that resolves every project id to `dir` and stubs the rest. */
export function stubAdapter(dir: string): StudioApiAdapter {
return {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({ id: "j", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }),
};
}
@@ -5,6 +5,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { trackEvent } from "../../telemetry/client";
import type { CaptionStyle } from "../types";
import { studioWriteHeaders } from "../../utils/studioFileVersion";
import { studioApiFetch } from "../../utils/studioApiFetch";
interface CaptionOverrideEntry {
wordId?: string;
@@ -93,11 +94,14 @@ export function useCaptionSync(projectId: string | null) {
const seqAtSave = editSeqRef.current;
const overrides = buildOverrides(state.model);
fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), {
method: "PUT",
headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
body: JSON.stringify(overrides, null, 2),
})
studioApiFetch(
buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`),
{
method: "PUT",
headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
body: JSON.stringify(overrides, null, 2),
},
)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// A newer edit may have re-armed the debounce while this PUT was in
@@ -171,7 +175,7 @@ export function useCaptionSync(projectId: string | null) {
let data: { content?: string };
try {
const res = await fetch(
const res = await studioApiFetch(
buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`),
);
if (!res.ok) return; // no overrides file yet — normal
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { studioApiFetch } from "../utils/studioApiFetch";
interface ServedProject {
id: string;
@@ -24,8 +25,7 @@ interface ProjectUnreachableBannerProps {
*
* The wording is deliberately narrower than "this project is gone". We can see
* which project this server serves; we cannot see why it does not serve this
* one, and nothing here is lost. See
* `docs/hyperframes/plans/sdk/2026-09-20-stale-project-id-remaining-doors-plan.md` §C.
* one, and nothing here is lost.
*/
export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBannerProps) {
const [served, setServed] = useState<ServedProject[] | null>(null);
@@ -35,7 +35,7 @@ export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBanner
// A list we could not read is not evidence of anything, so a failed fetch
// leaves `served` empty and the banner falls back to the vague wording
// rather than claiming this Studio serves nothing.
fetch("/api/projects")
studioApiFetch("/api/projects")
.then((res) => (res.ok ? res.json() : { projects: [] }))
.then((data: { projects?: ServedProject[] }) => {
if (!cancelled) setServed(data.projects ?? []);
@@ -0,0 +1,252 @@
// @vitest-environment happy-dom
import { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditingTypes";
import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
import { isStudioManualEditGestureLiveIn } from "./manualEditsDom";
import { readTranslatePx } from "./plainTranslate";
import { moveDomGroupBy } from "./domGroupMoveBy";
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
const mocks = vi.hoisted(() => ({ drag: vi.fn() }));
vi.mock("../../hooks/gsapRuntimeBridge", () => ({
tryGsapDragIntercept: mocks.drag,
tryGsapRotationIntercept: vi.fn(),
}));
vi.mock("../../hooks/useAnimatedPropertyCommit", () => ({
useAnimatedPropertyCommit: () => ({
commitAnimatedProperty: vi.fn(),
commitAnimatedProperties: vi.fn(),
}),
}));
vi.mock("../../hooks/useSafeGsapCommitMutation", () => ({
useGsapSaveFailureTelemetry: () => vi.fn(),
useSafeGsapCommitMutation: (commit: unknown) => commit,
}));
vi.mock("../../utils/studioSaveDiagnostics", async (importOriginal) => ({
...(await importOriginal<typeof import("../../utils/studioSaveDiagnostics")>()),
trackStudioEditBlocked: vi.fn(),
trackStudioSaveFailure: vi.fn(),
}));
import { mountGsapAwareEditing } from "../../hooks/useGsapAwareEditing.testHelpers";
afterEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
});
/** A preview iframe drawn at half size: 200x100 composition px in a 100x50 box. */
function preview(): Document {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const win = iframe.contentWindow!;
Object.defineProperty(win, "frameElement", { configurable: true, value: iframe });
Object.defineProperty(win, "innerWidth", { configurable: true, value: 200 });
Object.defineProperty(win, "innerHeight", { configurable: true, value: 100 });
iframe.getBoundingClientRect = () => new DOMRect(50, 40, 100, 50);
return iframe.contentDocument!;
}
const turned = (deg: number, scale: number) => {
const r = (deg * Math.PI) / 180;
return [scale * Math.cos(r), scale * Math.sin(r), -scale * Math.sin(r), scale * Math.cos(r)];
};
/** An element whose box centre is `at` plus its own move carried through its ancestors' linear map. */
function layer(doc: Document, id: string, parent = [1, 0, 0, 1], gsap?: object) {
const element = Object.assign(doc.createElement("div"), gsap ? { _gsap: gsap } : {});
doc.body.append(element);
const own = () => {
if (!gsap) return readTranslatePx(element);
const read = (prop: string) => Number.parseFloat(element.style.getPropertyValue(prop)) || 0;
return { x: read(STUDIO_OFFSET_X_PROP), y: read(STUDIO_OFFSET_Y_PROP) };
};
element.getBoundingClientRect = () => {
const { x, y } = own();
const [a, b, c, d] = parent as [number, number, number, number];
return new DOMRect(60 + a * x + c * y - 20, 40 + b * x + d * y - 10, 40, 20);
};
const selection = {
element,
id,
selector: `#${id}`,
label: id,
capabilities: { canApplyManualOffset: true },
} as unknown as DomEditSelection;
const centre = () => {
const r = element.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
};
return { element, selection, centre };
}
function mountGroupCommit() {
const showToast = vi.fn();
const save = vi.fn().mockResolvedValue(undefined);
const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
const { editing, root } = mountGsapAwareEditing({ showToast, stageElementPositionOffset });
const commit = (u: DomEditGroupPathOffsetCommit[], o?: { refusalToast?: boolean }) =>
editing().handleGsapAwareGroupPathOffsetCommit(u, o);
const moveBy = (moves: Parameters<typeof moveDomGroupBy>[0]) =>
act(() => moveDomGroupBy(moves, (u) => commit(u, { refusalToast: false })));
return {
commit: (u: DomEditGroupPathOffsetCommit[]) => commit(u),
moveBy,
showToast,
stageElementPositionOffset,
root,
};
}
const moved = (from: { x: number; y: number }, to: { x: number; y: number }) => ({
x: to.x - from.x,
y: to.y - from.y,
});
describe("handleDomGroupMoveBy", () => {
it("moves each box by its delta in composition px, saved under one undo entry", async () => {
const doc = preview();
const offset = layer(doc, "offset");
offset.element.style.setProperty("translate", "30px 0px");
const halved = layer(doc, "halved", [0.5, 0, 0, 0.5]);
const before = [offset.centre(), halved.centre()];
const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit();
await moveBy([
{ selection: offset.selection, delta: { x: 10, y: 0 } },
{ selection: halved.selection, delta: { x: 0, y: -5 } },
]);
expect(moved(before[0]!, offset.centre())).toEqual({ x: 10, y: 0 });
expect(moved(before[1]!, halved.centre())).toEqual({ x: 0, y: -5 });
const calls = stageElementPositionOffset.mock.calls as unknown as unknown[][];
expect(calls.map((call) => call[1])).toEqual([
{ x: 40, y: 0 },
{ x: 0, y: -10 },
]);
expect(calls[0]![3]).toMatch(/^group-drag:\d+$/);
expect(calls[1]![3]).toBe(calls[0]![3]);
expect(showToast).not.toHaveBeenCalled();
expect(isStudioManualEditGestureLiveIn(doc)).toBe(false);
act(() => root.unmount());
});
it("lands exactly under a half-scale parent with a rotated ancestor", async () => {
const doc = preview();
const tilted = layer(doc, "tilted", turned(30, 0.5));
const before = tilted.centre();
const { moveBy, root } = mountGroupCommit();
await moveBy([{ selection: tilted.selection, delta: { x: 10, y: 0 } }]);
const delta = moved(before, tilted.centre());
// Exact to the 0.001 px a translate is written with.
expect(delta.x).toBeCloseTo(10, 3);
expect(delta.y).toBeCloseTo(0, 3);
act(() => root.unmount());
});
it("moves a member GSAP positions through the GSAP write, planned and written at one delta", async () => {
mocks.drag.mockResolvedValue({ status: "persisted" });
const doc = preview();
const animated = layer(doc, "animated", [1, 0, 0, 1], { renderTransform: () => {} });
const before = animated.centre();
const { moveBy, stageElementPositionOffset, root } = mountGroupCommit();
await moveBy([{ selection: animated.selection, delta: { x: 10, y: 0 } }]);
const write = mocks.drag.mock.calls.find((call) => !call[6]?.preflightOnly);
expect(write?.[0]).toBe(animated.selection);
expect(write?.[1]).toEqual({ x: 10, y: 0 });
const plan = mocks.drag.mock.calls.find((call) => call[6]?.preflightOnly);
expect(plan?.[1]).toEqual(write?.[1]);
expect(moved(before, animated.centre())).toEqual({ x: 10, y: 0 });
expect(stageElementPositionOffset).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("skips a zero delta, and writes nothing when every delta is zero", async () => {
const doc = preview();
const still = layer(doc, "still");
const { moveBy, stageElementPositionOffset, root } = mountGroupCommit();
await moveBy([{ selection: still.selection, delta: { x: 0, y: 0 } }]);
expect(stageElementPositionOffset).not.toHaveBeenCalled();
act(() => root.unmount());
});
it.each([
[
"a member that can't take a manual move",
(doc: Document) => {
const locked = layer(doc, "locked");
Object.assign(locked.selection.capabilities, {
canApplyManualOffset: false,
reasonIfDisabled: "Locked by a helper loop.",
});
return locked;
},
/helper loop/,
],
[
"a member an animation took over",
(doc: Document) => layer(doc, "folded", [1, 0, 0, 1], { x: "94px" }),
/animation took over/,
],
[
"a member the GSAP write refuses",
(doc: Document) => {
mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" });
return layer(doc, "blocked", [1, 0, 0, 1], { renderTransform: () => {} });
},
new GsapEditBlockedError("no-selector").message,
],
])("refuses %s before any write, without a Studio toast", async (_, make, reason) => {
const doc = preview();
const fine = layer(doc, "fine");
const refused = make(doc);
const before = [fine.centre(), refused.centre()];
const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit();
await expect(
moveBy([
{ selection: fine.selection, delta: { x: 10, y: 0 } },
{ selection: refused.selection, delta: { x: 10, y: 0 } },
]),
).rejects.toThrow(reason);
expect(stageElementPositionOffset).not.toHaveBeenCalled();
expect(mocks.drag.mock.calls.some((call) => !call[6]?.preflightOnly)).toBe(false);
expect(showToast).not.toHaveBeenCalled();
expect([fine.centre(), refused.centre()]).toEqual(before);
expect(isStudioManualEditGestureLiveIn(doc)).toBe(false);
act(() => root.unmount());
});
it.each([
["an animation took over", () => layer(preview(), "folded", [1, 0, 0, 1], { x: "94px" }), true],
[
"the GSAP write refuses",
() => {
mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" });
return layer(preview(), "blocked", [1, 0, 0, 1], { renderTransform: () => {} });
},
false,
],
])("still toasts on a canvas group drag when %s", async (_, make, plainTranslate) => {
const { selection } = make();
const { commit, showToast, root } = mountGroupCommit();
await expect(
act(() => commit([{ selection, next: { x: 1, y: 2 }, plainTranslate }])),
).rejects.toThrow();
expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
act(() => root.unmount());
});
});
@@ -0,0 +1,95 @@
import type { DomEditSelection } from "./domEditing";
import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
import { canCanvasNudgeTargets } from "./domEditNudge";
import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
import { countStudioManualEditSave } from "./manualEditsDom";
import {
applyManualOffsetDragCommit,
createManualOffsetDragMember,
endManualOffsetDragMembers,
restoreManualOffsetDragMembers,
toTopScreenPoint,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
export interface DomGroupMove {
selection: DomEditSelection;
/** Composition px: the element's box moves by exactly this in the film. */
delta: { x: number; y: number };
}
/** Composition px to the top page's px; the preview frames only scale and shift. */
function toScreen(doc: Document, delta: { x: number; y: number }) {
const origin = toTopScreenPoint(doc, { x: 0, y: 0 });
const moved = toTopScreenPoint(doc, delta);
if (!origin || !moved) throw new Error("The preview has no measurable size.");
return { x: moved.x - origin.x, y: moved.y - origin.y };
}
function memberFor(
selection: DomEditSelection,
scale: { x: number; y: number },
): ManualOffsetDragMember {
const { element } = selection;
const box = element.getBoundingClientRect();
const result = createManualOffsetDragMember({
key: selection.id ?? selection.selector ?? selection.label,
selection,
element,
rect: {
left: box.left,
top: box.top,
width: box.width,
height: box.height,
editScaleX: scale.x,
editScaleY: scale.y,
},
gesture: "drag",
});
if (!result.ok) throw new Error(result.reason);
return result.member;
}
/**
* Moves each element by its delta as one group drag would drop it: one save, one undo entry.
* Any member that can't move refuses the whole call before anything is drawn or written.
*/
export async function moveDomGroupBy(
moves: DomGroupMove[],
commitGroup: (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown>,
): Promise<void> {
const moving = moves.filter(({ delta }) => delta.x !== 0 || delta.y !== 0);
if (moving.length === 0) return;
const selections = moving.map(({ selection }) => selection);
if (!canCanvasNudgeTargets(selections)) {
const blocked = selections.find((s) => !s.capabilities.canApplyManualOffset);
throw new Error(blocked?.capabilities.reasonIfDisabled ?? `${blocked?.label} can't be moved.`);
}
const screens = moving.map(({ selection, delta }) =>
toScreen(selection.element.ownerDocument, delta),
);
const members: ManualOffsetDragMember[] = [];
try {
for (const selection of selections)
members.push(memberFor(selection, toScreen(selection.element.ownerDocument, { x: 1, y: 1 })));
} catch (error) {
restoreManualOffsetDragMembers(members);
throw error;
}
const updates = members.map((member, index) => ({
selection: member.selection,
next: applyManualOffsetDragCommit(member, screens[index]!.x, screens[index]!.y),
plainTranslate: member.plainTranslate,
}));
try {
await countStudioManualEditSave(members[0]!.element, () => commitGroup(updates));
} catch (error) {
for (const member of members) {
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken))
restoreStudioPathOffset(member.element, member.initialPathOffset);
}
throw error;
} finally {
endManualOffsetDragMembers(members);
}
}
@@ -5,7 +5,7 @@ import type { GestureState } from "./domEditOverlayGestures";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import {
hasStudioPendingEdits,
revertNewestStudioPendingEdit,
paintBackNewestStudioPendingEdit,
} from "../../utils/studioPendingEdits";
afterEach(() => {
@@ -59,13 +59,13 @@ it("a drag whose save is still running can be painted back at once, and shown ag
const moved = element.style.getPropertyValue("translate");
expect(moved).not.toBe("40px 30px");
const showAgain = revertNewestStudioPendingEdit();
const shown = paintBackNewestStudioPendingEdit();
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
expect(revertNewestStudioPendingEdit()).toBeNull();
showAgain!();
expect(paintBackNewestStudioPendingEdit()).toBeNull();
shown!.showAgain();
expect(element.style.getPropertyValue("translate")).toBe(moved);
saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
expect(revertNewestStudioPendingEdit()).toBeNull();
expect(paintBackNewestStudioPendingEdit()).toBeNull();
});
@@ -289,6 +289,39 @@ describe("a drag moves only with its own pointer pressed", () => {
);
});
describe("an outside change during a drag, on a preview that can swap scenes in place", () => {
it("leaves the dragged node in place and shows the change after the drop, loaded fresh", async () => {
const { live, overlay, box } = mountEditor(false);
const swapScenes = vi.fn((html: string) => {
live.contentDocument!.body.innerHTML = html.replace(/^[\s\S]*<body>|<\/body>[\s\S]*$/g, "");
});
Object.assign(live.contentWindow!, { __hfSwapScenes: swapScenes });
vi.stubGlobal(
"fetch",
async () => new Response(served("").contentDocument!.documentElement.outerHTML),
);
const dragged = byId(live, "title");
pointer(box, "pointerdown", 150, 150);
pointer(overlay, "pointermove", 190, 170);
act(() => api().refreshPlayer());
await settle();
expect(swapScenes, "swapped under the pointer").not.toHaveBeenCalled();
expect(byId(live, "title")).toBe(dragged);
const beforeDrop = await paintShadow(api, served("?_t=1"));
expect(api().iframeRef.current, "promoted under the pointer").toBe(live);
pointer(overlay, "pointerup", 190, 170);
await settle();
expect(file.title).toBe("translate: 40px 20px");
const fresh = served("?_t=2");
expect(await paintShadow(api, fresh)).toBeGreaterThan(beforeDrop);
expect(api().iframeRef.current).toBe(fresh);
expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
vi.unstubAllGlobals();
});
});
type Editor = ReturnType<typeof mountEditor>;
describe("every way a drag ends without a drop clears its mark and promotes the held reload", () => {
@@ -78,4 +78,12 @@ export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
true,
);
// A host whose mounted API authenticates with same-origin cookies sets this; studioApiFetch then sends them, and its
// requests share the preview media's sockets again.
export const STUDIO_API_SAME_ORIGIN_CREDENTIALS = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_API_SAME_ORIGIN_CREDENTIALS"],
false,
);
import { resolveEnabledSdkFamilies } from "../../utils/sdkCutoverPolicy";
@@ -146,12 +146,12 @@ function getRectCenter(element: HTMLElement): Point | null {
return null;
}
let point = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
return toTopScreenPoint(element.ownerDocument, point);
}
let win: Window | null = element.ownerDocument.defaultView;
export function toTopScreenPoint(doc: Document, point: Point): Point | null {
let win: Window | null = doc.defaultView;
while (win) {
const frameElement = getFrameElement(win);
if (!frameElement) break;
@@ -166,7 +166,6 @@ function getRectCenter(element: HTMLElement): Point | null {
};
win = frameElement.ownerDocument.defaultView;
}
return point;
}
@@ -1,41 +1,16 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import type { DomEditSelection } from "./domEditingTypes";
import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
import { mountGroupSiblings, stableSelectionFor } from "../../hooks/domSelectionTestHarness";
import { selectorFor } from "./motionPathSelection";
afterEach(() => {
document.body.innerHTML = "";
});
function selectionFor(el: HTMLElement): DomEditSelection {
const selector = buildStableSelector(el);
return {
element: el,
id: el.id || undefined,
hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
sourceFile: "index.html",
dataAttributes: { start: "0", duration: "2" },
} as unknown as DomEditSelection;
}
function mountGroupSiblings(): HTMLElement[] {
document.body.innerHTML = `
<div id="scene" class="clip" data-start="0" data-duration="2">
<div class="group"></div>
<div class="group"></div>
<div class="group"></div>
</div>
`;
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
}
describe("selectorFor", () => {
it("addresses one element for a class-only sibling", () => {
const groups = mountGroupSiblings();
const selector = selectorFor(selectionFor(groups[2]!));
const groups = mountGroupSiblings(3);
const selector = selectorFor(stableSelectionFor(groups[2]!));
// The bare ".group" both measured home off the FIRST sibling and wrote the
// new motion path onto all three.
@@ -48,7 +23,7 @@ describe("selectorFor", () => {
document.body.innerHTML = `<div id="hero"></div>`;
const el = document.querySelector<HTMLElement>("#hero")!;
expect(selectorFor(selectionFor(el))).toBe("#hero");
expect(selectorFor(stableSelectionFor(el))).toBe("#hero");
});
it("returns null with no selection", () => {
@@ -56,8 +31,8 @@ describe("selectorFor", () => {
});
it("returns null when no rung addresses one element", () => {
const groups = mountGroupSiblings();
const selection = selectionFor(groups[1]!);
const groups = mountGroupSiblings(3);
const selection = stableSelectionFor(groups[1]!);
groups[1]!.remove();
expect(selectorFor(selection)).toBeNull();
@@ -1,4 +1,5 @@
import { buildProjectApiPath } from "../../utils/projectRouting";
import { studioApiFetch } from "../../utils/studioApiFetch";
type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
@@ -12,7 +13,7 @@ const pending = new Map<string, PendingProbe[]>();
async function sendBatch(projectId: string, sourceFile: string, probes: PendingProbe[]) {
let exists: boolean[] = [];
try {
const response = await fetch(
const response = await studioApiFetch(
buildProjectApiPath(
projectId,
`/file-mutations/probe-elements/${encodeURIComponent(sourceFile)}`,
@@ -20,6 +20,7 @@ import {
} from "./propertyPanelHelpers";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { runWhenInputIdle } from "./overlayFrameLoop";
import { studioApiFetch } from "../../utils/studioApiFetch";
/* ------------------------------------------------------------------ */
/* Font helper functions */
@@ -135,7 +136,7 @@ let fontListsRequest: Promise<void> | null = null;
const fontListListeners = new Set<() => void>();
async function fetchFontList(url: string): Promise<string[]> {
const data = (await (await fetch(url)).json()) as { fonts?: unknown };
const data = (await (await studioApiFetch(url)).json()) as { fonts?: unknown };
if (!Array.isArray(data.fonts)) throw new Error(`${url} returned no font list`);
return data.fonts as string[];
}
@@ -356,7 +357,7 @@ export function FontFamilyField({
const importSystemFont = async (family: string): Promise<ImportedFontAsset | null> => {
if (!onImportFonts) return null;
const response = await fetch(`/api/fonts/file?family=${encodeURIComponent(family)}`);
const response = await studioApiFetch(`/api/fonts/file?family=${encodeURIComponent(family)}`);
if (!response.ok) return null;
const blob = await response.blob();
const ext = response.headers.get("Content-Disposition")?.match(/\.(\w+)"?$/)?.[1] ?? "ttf";
@@ -29,6 +29,7 @@ import {
type ColorGradingPresetPreviews,
type ColorGradingPreviewOptions,
} from "./useColorGradingPreviews";
import { studioApiFetch } from "../../utils/studioApiFetch";
export type { ColorGradingPresetPreviews, ColorGradingPreviewOptions };
@@ -290,7 +291,7 @@ export function useColorGradingController({
return;
}
const controller = new AbortController();
fetch(cacheKey, { signal: controller.signal })
studioApiFetch(cacheKey, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) return { ok: false as const };
const data: MediaMetadataResponse | null = await response.json();
@@ -24,6 +24,7 @@ import {
import { fxAutomationTarget, type HfAutomationLane } from "@hyperframes/core/audio-automation";
import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
import { clipAudioOnItsClock, type ClipClock } from "./clipAudioClock.js";
import { studioApiFetch } from "../../utils/studioApiFetch";
/** Decode rate every carve measurement shares — see `measureCarve`. */
const DECODE_SAMPLE_RATE = 48000;
@@ -95,7 +96,7 @@ export async function measureCarve(
if (!Ctor) return null;
// Each clip as it plays: trimmed to its in-point and duration, and at its own speed.
const decode = async (relative: string, clock: ClipClock): Promise<Float32Array> => {
const res = await fetch(new URL(relative, doc.baseURI).href);
const res = await studioApiFetch(new URL(relative, doc.baseURI).href);
const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
await res.arrayBuffer(),
);
@@ -26,6 +26,7 @@ import { trackLeveller } from "./audioFxTelemetry.js";
import type { DomEditSelection } from "./domEditingTypes";
import { clipSourceWindow, readClipClock } from "./clipAudioClock.js";
import { useAuditionTransport } from "./useAuditionTransport.js";
import { studioApiFetch } from "../../utils/studioApiFetch";
/**
* Rate the track is decoded at. Analysis is self-consistent because it reads
@@ -63,7 +64,7 @@ export function useFxLevelling(
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
.webkitOfflineAudioContext;
if (!Ctor) return null;
const res = await fetch(new URL(src, doc.baseURI).href);
const res = await studioApiFetch(new URL(src, doc.baseURI).href);
const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
await res.arrayBuffer(),
);
@@ -17,6 +17,7 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
import type { FeedbackContext } from "./feedbackTrigger";
import { isMediaFile } from "../../utils/mediaTypes";
import { studioApiFetch } from "../../utils/studioApiFetch";
/** Written by `hyperframes init`; absent in a hand-made or copied project. */
const CONFIG_FILE = "hyperframes.json";
@@ -53,7 +54,7 @@ export async function captureProjectProvenance(
if (!scaffolded) return;
try {
const res = await fetch(
const res = await studioApiFetch(
buildProjectApiPath(projectId, `/files/${encodeURIComponent(CONFIG_FILE)}`),
);
if (!res.ok) return;
@@ -126,6 +126,7 @@ describe("NLEProvider — composition source-map scoping", () => {
expect(observed.filter((map) => map.size === 0)).toHaveLength(2);
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b/files/index.html", {
signal: expect.any(AbortSignal),
credentials: "omit",
});
expect(observed.at(-1)).toEqual(new Map([["b-comp", "compositions/b.html"]]));
@@ -9,6 +9,7 @@ import { setCompositionSourceMap } from "../editor/domEditingDom";
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { createStableContext } from "../../utils/hmrStableContext";
import { studioApiFetch } from "../../utils/studioApiFetch";
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
return compositionLoading;
@@ -209,7 +210,7 @@ export function NLEProvider({
setCompositionSourceMap(emptyMap);
onCompIdToSrcChangeRef.current?.(emptyMap);
fetch(buildProjectApiPath(projectId, `/files/index.html`), {
studioApiFetch(buildProjectApiPath(projectId, `/files/index.html`), {
signal: controller.signal,
})
.then((r) => {
@@ -12,7 +12,7 @@ import {
type RefObject,
} from "react";
import { Player } from "../../player";
import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
import type { NLEContextValue } from "./NLEContext";
import {
DEFAULT_PREVIEW_ZOOM,
canStartPreviewPan,
@@ -29,16 +29,18 @@ import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesS
import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay";
import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
import { PreviewPoster, usePreviewPoster } from "./PreviewPoster";
interface NLEPreviewProps {
projectId: string;
interface NLEPreviewProps extends Pick<
NLEContextValue,
| "projectId"
| "onIframeLoad"
| "previewSlots"
| "onShadowIframeLoad"
| "onShadowReadyChange"
| "onShadowError"
| "setShadowIframeNode"
| "resetPreviewSlots"
> {
iframeRef: RefObject<HTMLIFrameElement | null>;
onIframeLoad: () => void;
previewSlots: PreviewIframeSlot[];
onShadowIframeLoad: (gen: number) => void;
onShadowReadyChange: (gen: number, ready: boolean) => void;
onShadowError: (gen: number, message: string) => void;
setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
resetPreviewSlots: () => void;
onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean;
directUrl?: string;
@@ -6,6 +6,7 @@ import {
type ThumbnailRequest,
} from "../../player/lib/thumbnailScheduler";
import type { PreviewCompositionSize } from "../../utils/previewCompositionSize";
import { studioApiFetch } from "../../utils/studioApiFetch";
/** Frame 0 as the thumbnail route last rendered it; `cachedOnly` never starts a render. */
function previewPosterUrl(projectId: string, cachedOnly: boolean): string {
@@ -107,7 +108,7 @@ export function renderPosterForNextOpen(projectId: string): void {
priority: "overscan",
discardWhenReleased: true,
load: async (signal) => {
await (await fetch(url, { signal })).arrayBuffer();
await (await studioApiFetch(url, { signal })).arrayBuffer();
return { value: { kind: "image", url, aspect: 16 / 9 }, weight: 0 };
},
};
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { studioApiFetch } from "../../utils/studioApiFetch";
/**
* What the dev server knows about this machine's FFmpeg, as reported by
@@ -59,7 +60,7 @@ function parseStatus(body: unknown): ProbeResult {
async function probe(): Promise<ProbeResult> {
try {
const res = await fetch("/api/environment/ffmpeg");
const res = await studioApiFetch("/api/environment/ffmpeg");
return res.ok ? parseStatus(await res.json()) : null;
} catch {
return null;
@@ -8,6 +8,7 @@ import { generateId } from "../../utils/generateId";
import { readServerError } from "./serverError";
import { ffmpegInstallMessage, useFfmpegStatus } from "./useFfmpegStatus";
import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger";
import { studioApiFetch } from "../../utils/studioApiFetch";
export interface RenderJob {
id: string;
@@ -126,7 +127,7 @@ export function useRenderQueue(
const loadRenders = useCallback(async () => {
if (!projectId) return;
try {
const res = await fetch(buildProjectApiPath(projectId, `/renders`));
const res = await studioApiFetch(buildProjectApiPath(projectId, `/renders`));
if (!res.ok) {
setLoadError(`Couldn't load render history (server error ${res.status}).`);
return;
@@ -262,7 +263,7 @@ export function useRenderQueue(
}
let res: Response;
try {
res = await fetch(buildProjectApiPath(projectId, `/render`), {
res = await studioApiFetch(buildProjectApiPath(projectId, `/render`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -376,7 +377,7 @@ export function useRenderQueue(
),
);
try {
const res = await fetch(`/api/render/${jobId}/cancel`, { method: "POST" });
const res = await studioApiFetch(`/api/render/${jobId}/cancel`, { method: "POST" });
if (!res.ok && res.status !== 404) {
setActionError("Couldn't cancel on the server — the render may still be running.");
return;
@@ -403,7 +404,7 @@ export function useRenderQueue(
setActionError(null);
closeActiveEventSource(jobId);
try {
const res = await fetch(`/api/render/${jobId}`, { method: "DELETE" });
const res = await studioApiFetch(`/api/render/${jobId}`, { method: "DELETE" });
if (!res.ok) {
setActionError("Couldn't delete the render — it's still on disk.");
return;
@@ -16,6 +16,7 @@ import {
import { AudioRow } from "./AudioRow";
import { GlobalAssetsView } from "./GlobalAssetsView";
import { AssetCard, FontRow } from "./AssetCard";
import { studioApiFetch } from "../../utils/studioApiFetch";
interface AssetsTabProps {
projectId: string;
@@ -217,7 +218,7 @@ export const AssetsTab = memo(function AssetsTab({
useEffect(() => {
if (manifest404Ref.current.has(projectId)) return;
let cancelled = false;
fetch(buildProjectApiPath(projectId, `/preview/.media/manifest.jsonl`))
studioApiFetch(buildProjectApiPath(projectId, `/preview/.media/manifest.jsonl`))
.then((r) => {
if (!r.ok) {
manifest404Ref.current.add(projectId);
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { studioApiFetch } from "../../utils/studioApiFetch";
// Cross-project asset view — the global media-use cache (~/.media), fetched from
// /api/assets/global. Self-contained (owns its fetch + state) so AssetsTab stays
@@ -43,7 +44,7 @@ export function GlobalAssetsView({ searchQuery }: { searchQuery: string }) {
const [records, setRecords] = useState<GlobalAssetRecord[] | null>(null);
useEffect(() => {
let cancelled = false;
fetch("/api/assets/global")
studioApiFetch("/api/assets/global")
.then((r) => (r.ok ? r.json() : { assets: [] }))
.then((d) => {
if (!cancelled) setRecords(Array.isArray(d.assets) ? d.assets : []);
@@ -24,6 +24,7 @@ const SAVES_AN_EDIT = {
handleDomAttributeBatchCommit: true,
handleDomPathOffsetCommit: true,
handleDomGroupPathOffsetCommit: true,
handleDomGroupMoveBy: true,
handleDomZIndexReorderCommit: true,
handleDomBoxSizeCommit: true,
handleDomRotationCommit: true,
@@ -179,6 +180,7 @@ export function DomEditProvider({
handleDomAttributeBatchCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomGroupMoveBy,
handleDomZIndexReorderCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
@@ -274,6 +276,7 @@ export function DomEditProvider({
handleDomAttributeBatchCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomGroupMoveBy,
handleDomZIndexReorderCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
@@ -350,6 +353,7 @@ export function DomEditProvider({
handleDomAttributeBatchCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomGroupMoveBy,
handleDomZIndexReorderCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
@@ -4,6 +4,7 @@ import type React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { DomEditSelection } from "../components/editor/domEditing";
import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
export function installReactActEnvironment(): void {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
@@ -61,3 +62,25 @@ export function makeSelection(label: string, element: HTMLElement): DomEditSelec
},
};
}
/** Built as getDomLayerPatchTarget builds it: a bare class for an element with no id or hf-id. */
export function stableSelectionFor(el: HTMLElement): DomEditSelection {
const selector = buildStableSelector(el);
return {
element: el,
id: el.id || undefined,
hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
sourceFile: "index.html",
dataAttributes: { start: "0", duration: "2" },
} as unknown as DomEditSelection;
}
export function mountGroupSiblings(count: number, withIds = false): HTMLElement[] {
const groups = Array.from({ length: count }, (_, i) =>
withIds ? `<div class="group" id="group-${i}"></div>` : `<div class="group"></div>`,
);
document.body.innerHTML = `<div id="scene" class="clip" data-start="0" data-duration="2">${groups.join("")}</div>`;
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
}
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { expect, it } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { PatchOperation } from "../utils/sourcePatcher";
import { commitDomStyles, type DomStyleCommitContext } from "./domStyleCommit";
const selection = {
id: "target",
selector: "#target",
label: "Target",
tagName: "div",
sourceFile: "index.html",
compositionPath: "index.html",
textFields: [],
inlineStyles: {},
computedStyles: {},
dataAttributes: {},
capabilities: { canSelect: true, canEditStyles: true },
} as unknown as DomEditSelection;
async function savedPatches(styles: Record<string, string>) {
const saves: Array<{ operations: PatchOperation[]; label?: string }> = [];
const context: DomStyleCommitContext = {
activeCompPath: "index.html",
previewIframeRef: { current: null },
showToast: () => undefined,
versions: new Map(),
persistDomEditOperations: (async (
_selection: DomEditSelection,
operations: PatchOperation[],
options?: { label?: string },
) => {
saves.push({ operations, label: options?.label });
}) as unknown as DomStyleCommitContext["persistDomEditOperations"],
};
await commitDomStyles(context, selection, styles);
return saves;
}
const written = (operations: PatchOperation[]) =>
operations.map((op) => [(op as { property: string }).property, (op as { value: string }).value]);
it("writes a style named the JavaScript way under its CSS name", async () => {
const saves = await savedPatches({ backgroundColor: "#ff0000" });
expect(saves.map((save) => written(save.operations))).toEqual([
[["background-color", "#ff0000"]],
]);
});
it("saves a map mixing both forms as one patch and one undo step, all under CSS names", async () => {
const saves = await savedPatches({
borderRadius: "4",
"border-color": "red",
WebkitTextStroke: "1px black",
msTransform: "none",
});
expect(saves).toHaveLength(1);
expect(saves[0]!.label).toBe("Edit layer style");
expect(written(saves[0]!.operations)).toEqual([
["border-radius", "4px"],
["border-color", "red"],
["-webkit-text-stroke", "1px black"],
["-ms-transform", "none"],
]);
});
it("keeps a custom property's name exactly as given", async () => {
const saves = await savedPatches({ "--brand": "#123456", "--brandAccent": "#654321" });
expect(written(saves[0]!.operations)).toEqual([
["--brand", "#123456"],
["--brandAccent", "#654321"],
]);
});
+8 -1
View File
@@ -1,5 +1,6 @@
import type { PatchOperation } from "../utils/sourcePatcher";
import {
cssPropertyName,
isImageBackgroundValue,
isManualGeometryStyleProperty,
normalizeDomEditStyleValue,
@@ -43,8 +44,14 @@ export interface DomStyleCommitContext {
export async function commitDomStyles(
context: DomStyleCommitContext,
selection: DomEditSelection,
styles: Record<string, string>,
stylesInEitherCase: Record<string, string>,
): Promise<DomEditCommitOutcome> {
const styles = Object.fromEntries(
Object.entries(stylesInEitherCase).map(([property, value]) => [
cssPropertyName(property),
value,
]),
);
const entries = Object.entries(styles);
if (entries.length === 0) return domEditCommitDeclined("no-selection");
if (entries.some(([property]) => isManualGeometryStyleProperty(property)))
@@ -75,3 +75,13 @@ export function computeDraggedGsapPosition(
baseGsapY,
};
}
/** 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`);
element.removeAttribute("data-hf-drag-initial-offset-x");
element.removeAttribute("data-hf-drag-initial-offset-y");
}
+75 -164
View File
@@ -14,6 +14,7 @@ import {
type GsapDragCommitCallbacks,
} from "./gsapDragCommit";
import { usePlayerStore } from "../player/store/playerStore";
import { liveTween, previewWith } from "./gsapParsedTween.test-helpers";
// Minimal selection whose element has no drag-baseline attributes (origX/Y = 0).
const selection = (): DomEditSelection =>
@@ -111,175 +112,86 @@ function recordingCallbacks(): {
};
}
describe("commitGsapPositionFromDrag — flat tween", () => {
beforeEach(() => {
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
});
describe("commitGsapPositionFromDrag — at a selected keyframe or past the tween", () => {
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
const puck = () => {
const element = document.createElement("div");
element.id = "puck-a";
return { id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection;
};
const preview = (
sel: DomEditSelection,
anim: GsapAnimation,
ends: Record<string, [number, number]>,
from = false,
) =>
previewWith(sel.element, [
liveTween(
sel.element,
{ start: anim.resolvedStart!, duration: anim.duration!, vars: anim.properties, ends },
{ from },
),
]);
it("extends the existing tween (never spawns a parallel one) when dragged OUTSIDE its range", async () => {
// fallow-ignore-next-line code-duplication
usePlayerStore.setState({ currentTime: 6 }); // outside [1.2, 3.4]
const { types, callbacks } = recordingCallbacks();
await commitGsapPositionFromDrag(
selection(),
flatTween(),
{ x: -100, y: 0 },
{ x: 0, y: 0 },
null,
"#puck-a",
callbacks,
);
expect(types).toContain("convert-to-keyframes");
expect(types).toContain("replace-with-keyframes"); // the extend
expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween
});
it("adds a keyframe at the playhead when dragged INSIDE its range", async () => {
// fallow-ignore-next-line code-duplication
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4]
// fallow-ignore-next-line code-duplication
const { types, callbacks } = recordingCallbacks();
await commitGsapPositionFromDrag(
selection(),
flatTween(),
{ x: -100, y: 0 },
{ x: 0, y: 0 },
null,
"#puck-a",
callbacks,
);
expect(types).toContain("add-keyframe");
expect(types).not.toContain("add-with-keyframes");
});
it("MODIFIES the selected keyframe (no extend) when one is selected, even past the tween end", async () => {
// User clicked the 100% diamond (activeKeyframePct=100), playhead drifted past
// the end. Expect: convert + add-keyframe AT 100% — not replace-with-keyframes.
it("changes the selected keyframe without extending, even with the playhead past the end", async () => {
usePlayerStore.setState({ currentTime: 6, activeKeyframePct: 100 }); // outside [1.2, 3.4]
// fallow-ignore-next-line code-duplication
const { types, mutations, callbacks } = recordingCallbacks();
const { mutations, callbacks } = recordingCallbacks();
const sel = puck();
await commitGsapPositionFromDrag(
selection(),
sel,
flatTween(),
{ x: -100, y: 0 },
{ x: 0, y: 0 },
null,
"#puck-a",
preview(sel, flatTween(), { x: [0, -260] }),
callbacks,
);
expect(types).toContain("add-keyframe");
expect(types).not.toContain("replace-with-keyframes"); // not extended
const addKf = mutations.find((m) => m.type === "add-keyframe");
expect(addKf?.percentage).toBe(100); // modified the selected endpoint
// consumed: cleared so the next free drag doesn't keep modifying
expect(mutations).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
position: 1.2,
duration: 2.2,
easeEach: "power1.out",
keyframes: [{ percentage: 100, properties: { x: -100, y: 0 } }],
}),
]);
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
// parked the playhead on the edited keyframe (1.2 start + 100% * 2.2 dur),
// so the edit is visible instead of rendering the base pose
expect(usePlayerStore.getState().requestedSeekTime).toBe(3.4);
});
});
describe("commitGsapPositionFromDrag — keyframed tween backfill", () => {
beforeEach(() => {
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
});
const keyframedTween = (): GsapAnimation =>
({
id: "#puck-a-kf",
targetSelector: "#puck-a",
method: "to",
resolvedStart: 1.2,
duration: 2.2,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: -260 } },
],
},
}) as unknown as GsapAnimation;
it("passes backfillDefaults so a newly-introduced prop doesn't move the other keyframes", async () => {
// Drag the 0% keyframe DOWN (introduces y on an x-only tween). The add-keyframe
// must carry backfillDefaults at the element's base so 100% gets y:0, not y:780.
usePlayerStore.setState({ currentTime: 1.2, activeKeyframePct: 0 });
const { mutations, callbacks } = recordingCallbacks();
await commitGsapPositionFromDrag(
selection(),
keyframedTween(),
{ x: 0, y: 780 }, // studioOffset: dragged straight down
{ x: 0, y: 0 }, // gsapPos → base falls back to {0,0} (selection has no base attrs)
null,
"#puck-a",
callbacks,
);
const addKf = mutations.find((m) => m.type === "add-keyframe");
expect(addKf).toBeDefined();
expect(addKf?.percentage).toBe(0); // edited the selected 0% keyframe
expect(addKf?.properties).toMatchObject({ y: 780 });
expect(addKf?.backfillDefaults).toEqual({ x: 0, y: 0 }); // base → 100% gets y:0
});
});
describe("commitGsapPositionFromDrag — from() tween dragged outside its range", () => {
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
const fromTween = (): GsapAnimation =>
({
id: "#title-from-400",
it("extends a from() tween to the playhead, keeping its parsed end as the old end", async () => {
usePlayerStore.setState({ currentTime: 2.2 }); // outside [0.4, 1.3]
const fromTween = {
id: "#title-from-400-position",
targetSelector: "#title",
method: "from",
propertyGroup: "position",
resolvedStart: 0.4,
duration: 0.9,
ease: "none",
properties: { y: 70 },
}) as unknown as GsapAnimation;
it("REPLACES the split position from() tween (no parallel tween → no drop jump)", async () => {
usePlayerStore.setState({ currentTime: 2.13 }); // outside [0.4, 1.3]
const types: string[] = [];
const mutations: Array<Record<string, unknown>> = [];
const callbacks: GsapDragCommitCallbacks = {
commitMutation: async (_s, m) => {
types.push(m.type as string);
mutations.push(m);
},
// After split-into-property-groups, the position group is a from() tween (no keyframes).
fetchAnimations: async () => [
{
id: "#title-from-400-position",
targetSelector: "#title",
method: "from",
propertyGroup: "position",
resolvedStart: 0.4,
duration: 0.9,
properties: { y: 70 },
} as unknown as GsapAnimation,
],
};
} as unknown as GsapAnimation;
const { types, mutations, callbacks } = recordingCallbacks();
const sel = puck();
await commitGsapPositionFromDrag(
selection(),
fromTween(),
sel,
fromTween,
{ x: 0, y: -333 },
{ x: 0, y: 0 },
null,
"#title",
preview(sel, fromTween, { y: [70, 0] }, true),
callbacks,
);
expect(types).toContain("split-into-property-groups");
expect(types).toContain("replace-with-keyframes");
expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween
const replace = mutations.find((m) => m.type === "replace-with-keyframes");
expect(replace?.animationId).toBe("#title-from-400-position"); // replaces the split from()
expect(types).toEqual(["replace-with-keyframes"]);
expect(mutations[0]).toMatchObject({
animationId: "#title-from-400-position",
position: 0.4,
duration: 1.8,
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { y: 70, x: 0 } },
{ percentage: 50, properties: { y: 0, x: 0 } },
{ percentage: 100, properties: { y: -333, x: 0 } },
],
});
});
});
@@ -581,32 +493,31 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
});
});
describe("commitGsapPositionFromDrag — keyframe/structural commits omit instantPatch", () => {
describe("commitGsapPositionFromDrag — keyframe commits omit instantPatch", () => {
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
it("a structural keyframe drag (convert-to-keyframes → add-keyframe) sets no instantPatch", async () => {
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] → convert + add-keyframe
it("a keyframe drag relies on the soft reload, not the instant patch", async () => {
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4]
const { commits, callbacks } = optionRecordingCallbacks();
const element = document.createElement("div");
const sel = { id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection;
const live = liveTween(element, {
start: 1.2,
duration: 2.2,
vars: { x: -260 },
ends: { x: [0, -260] },
});
await commitGsapPositionFromDrag(
selection(),
sel,
flatTween(),
{ x: -100, y: 0 },
{ x: 0, y: 0 },
null,
"#puck-a",
previewWith(element, [live]),
callbacks,
);
// The keyframe path is structural here (convert + add-keyframe) and must rely
// on the soft reload — none of its commits opt into the instant patch.
expect(commits.length).toBeGreaterThan(0);
for (const c of commits) {
expect(c.options.instantPatch).toBeUndefined();
}
const types = commits.map((c) => c.mutation.type);
expect(types).toContain("convert-to-keyframes");
expect(types).toContain("add-keyframe");
expect(commits.map((c) => c.mutation.type)).toEqual(["replace-with-keyframes"]);
expect(commits[0]!.options.instantPatch).toBeUndefined();
expect(commits[0]!.options.softReload).toBe(true);
});
});
+2 -105
View File
@@ -4,14 +4,10 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
STUDIO_ORIGINAL_WIDTH_ATTR,
STUDIO_ORIGINAL_HEIGHT_ATTR,
} from "../components/editor/manualEditsTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import type { CommitMutation } from "./gsapScriptCommitTypes";
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
@@ -295,104 +291,6 @@ export async function commitStaticGsapSize(
);
}
/** Rounded `n` when it's a positive finite number, else `fallback`. */
function positiveOr(n: number, fallback: number): number {
return Number.isFinite(n) && n > 0 ? roundToLayoutPx(n) : fallback;
}
/**
* Prior size for a keyframed resize: the existing global set's value, else the
* element's pre-resize size (the draft saved it on the element before mutating
* el.style.width/height). Falls back to the new size when neither is available.
*/
function resolvePriorSize(
sizeSet: GsapAnimation | null,
el: Element | null | undefined,
fallbackW: number,
fallbackH: number,
): { width: number; height: number } {
if (sizeSet) {
return {
width: positiveOr(Number(sizeSet.properties.width), fallbackW),
height: positiveOr(Number(sizeSet.properties.height), fallbackH),
};
}
const ow = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR) ?? "");
const oh = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR) ?? "");
return { width: positiveOr(ow, fallbackW), height: positiveOr(oh, fallbackH) };
}
/**
* Resize an *animated* element by keyframing its size at the current playhead,
* instead of a global `gsap.set` hold. Builds a width/height keyframe tween
* aligned to the element's existing animation: every base keyframe keeps the
* prior size, only the keyframe nearest the playhead gets the new size — so
* resizing one keyframe leaves the others unchanged. Replaces any prior global
* size set. Returns false when there's no usable range (caller falls back to the
* static set).
*/
export async function commitKeyframedSizeFromResize(
selection: DomEditSelection,
size: { width: number; height: number },
selector: string,
sizeSet: GsapAnimation | null,
animatedTween: GsapAnimation,
callbacks: GsapDragCommitCallbacks,
): Promise<boolean> {
const ts = resolveTweenStart(animatedTween) ?? 0;
const td = resolveTweenDuration(animatedTween);
if (!(td > 0)) return false;
const newW = roundToLayoutPx(size.width);
const newH = roundToLayoutPx(size.height);
const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
const ct = usePlayerStore.getState().currentTime;
const pct = Math.max(0, Math.min(100, Math.round(((ct - ts) / td) * 1000) / 10));
// Base keyframe percentages from the animated tween (flat tween → 0 & 100),
// plus the endpoints and the playhead. Each keeps the prior size except the
// keyframe at the playhead, which gets the new size.
const pcts = new Set<number>(
animatedTween.keyframes?.keyframes.map((k) => k.percentage) ?? [0, 100],
);
pcts.add(0);
pcts.add(100);
pcts.add(pct);
const keyframes = Array.from(pcts)
.sort((a, b) => a - b)
.map((p) => ({
percentage: p,
properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior },
}));
// Add the size keyframe tween FIRST, then delete the old global hold. The gesture
// transport applies both in one ordered batch; a plain commit fallback keeps the
// same recoverable ordering. Only the transaction's result triggers the reload.
const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
const target = newTweenTarget(selection);
if (!target) return false;
await callbacks.commitMutation(
selection,
{
type: "add-with-keyframes",
targetSelector: target,
position: roundTo3(ts),
duration: roundTo3(td),
keyframes,
},
sizeSet ? { label: addLabel, skipReload: true } : { label: addLabel, softReload: true },
);
if (sizeSet) {
await callbacks.commitMutation(
selection,
{ type: "delete", animationId: sizeSet.id },
{ label: "Resize layer", softReload: true },
);
}
return true;
}
// ── Whole-path offset (plain drag on animated element) ──────────────────
/**
@@ -438,7 +336,6 @@ export async function commitWholePathOffset(
const ts = resolveTweenStart(effectiveAnim);
const td = resolveTweenDuration(effectiveAnim);
const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease;
let kfs = effectiveAnim.keyframes?.keyframes ?? [];
if (kfs.length === 0) {
@@ -475,7 +372,7 @@ export async function commitWholePathOffset(
position: roundTo3(ts ?? 0),
duration: roundTo3(td || 1),
keyframes: shifted,
ease,
...keyframeEases(effectiveAnim),
},
{ label: "Move animation path", softReload: true, beforeReload: restoreOffset },
);
@@ -1,8 +1,7 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { resolveTweenStart } from "../utils/globalTimeCompiler";
import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
@@ -10,8 +9,9 @@ import {
type GsapDragCommitCallbacks,
computeCurrentPercentage,
parkPlayheadOnKeyframe,
materializeIfDynamic,
} from "./gsapDragCommit";
import type { GsapEditOutcome } from "./gsapEditOutcome";
import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
/**
* The tween's keyframes with one inserted at `percentage`. Any existing keyframe
@@ -38,237 +38,26 @@ export function buildTemporalArcKeyframes(
].sort((a, b) => a.percentage - b.percentage);
}
async function extendTweenAndAddKeyframe(
/** commitGsapPositionFromDrag's refusal, decided without writing. */
export function gsapPositionFromDragOutcome(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
targetTime: number,
tweenStart: number,
tweenDuration: number,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
backfillDefaults?: Record<string, number>,
): Promise<void> {
const tweenEnd = tweenStart + tweenDuration;
const newStart = Math.min(targetTime, tweenStart);
const newEnd = Math.max(targetTime, tweenEnd);
const newDuration = Math.max(0.01, newEnd - newStart);
const existingKfs = anim.keyframes?.keyframes ?? [];
const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
[];
for (const kf of existingKfs) {
const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
const props: Record<string, number | string> = { ...kf.properties };
for (const k of Object.keys(properties)) {
if (!(k in props) && backfillDefaults?.[k] != null) props[k] = backfillDefaults[k];
}
remappedKfs.push({ percentage: newPct, properties: props });
}
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
remappedKfs.push({ percentage: targetPct, properties });
remappedKfs.sort((a, b) => a.percentage - b.percentage);
await callbacks.commitMutation(
selection,
{
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: anim.targetSelector,
position: roundTo3(newStart),
duration: roundTo3(newDuration),
keyframes: remappedKfs,
},
{
label: `Move layer (extended keyframe)`,
keyframeAction: "add",
softReload: true,
beforeReload,
},
studioOffset: { x: number; y: number },
gsapPos: { x: number; y: number },
iframe: HTMLIFrameElement | null,
): GsapEditOutcome {
if (anim.arcPath?.enabled) return { status: "persisted" };
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
selection.element,
studioOffset,
gsapPos,
);
}
// fallow-ignore-next-line complexity
async function commitKeyframedPosition(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
backfillDefaults?: Record<string, number>,
): Promise<void> {
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const computedPct = computeCurrentPercentage(selection, anim);
const pct = activeKeyframePct ?? computedPct;
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
...(backfillDefaults ? { backfillDefaults } : {}),
},
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
);
if (activeKeyframePct != null) {
setActiveKeyframePct(null);
parkPlayheadOnKeyframe(anim, pct);
}
}
interface DragRuntimeGsap {
getProperty: (target: Element, key: string) => unknown;
set: (target: Element, vars: Record<string, unknown>) => void;
}
interface DragRuntimeTimeline {
seek: (time: number) => void;
}
interface DragRuntime {
gsapLib: DragRuntimeGsap;
el: Element;
mainTl: DragRuntimeTimeline;
}
function resolveDragRuntime(
iframe: HTMLIFrameElement | null | undefined,
selector: string | undefined,
): DragRuntime | null {
if (!iframe || !selector) return null;
const win = iframe.contentWindow as
| (Window & {
gsap?: Partial<DragRuntimeGsap>;
__timelines?: Record<string, Partial<DragRuntimeTimeline>>;
})
| null;
const gsap = win?.gsap;
if (typeof gsap?.getProperty !== "function" || typeof gsap.set !== "function") return null;
let el: Element | null = null;
try {
el = iframe.contentDocument?.querySelector(selector) ?? null;
} catch {
return null;
}
if (!el) return null;
const timelines = win?.__timelines;
const mainTl = timelines ? Object.values(timelines)[0] : undefined;
if (typeof mainTl?.seek !== "function") return null;
return {
gsapLib: gsap as DragRuntimeGsap,
el,
mainTl: mainTl as DragRuntimeTimeline,
};
}
// fallow-ignore-next-line complexity
async function commitFlatViaKeyframes(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
iframe?: HTMLIFrameElement | null,
selector?: string,
backfillDefaults?: Record<string, number>,
): Promise<void> {
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = resolveEditableTweenDuration(anim, selection);
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const outsideRange =
activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
const resolvedFromValues: Record<string, number | string> = {};
const runtime = resolveDragRuntime(iframe, selector);
if (runtime && ts !== null) {
const { gsapLib, el, mainTl } = runtime;
const draggedValues: Record<string, number> = {};
const channelsTheClearWipes = new Set([
...Object.keys(properties),
...PROPERTY_GROUPS.position,
]);
for (const key of channelsTheClearWipes) {
const v = Number(gsapLib.getProperty(el, key));
if (Number.isFinite(v)) draggedValues[key] = v;
}
try {
gsapLib.set(el, { clearProps: Object.keys(properties).join(",") });
mainTl.seek(ts);
for (const key of Object.keys(properties)) {
const v = Number(gsapLib.getProperty(el, key));
if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
}
mainTl.seek(ct);
} catch {
for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
} finally {
if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
}
}
if (outsideRange && ts !== null) {
const coalesceKey = `gsap:convert-drag:${anim.id}`;
await callbacks.commitMutation(
selection,
{
type: "convert-to-keyframes",
animationId: anim.id,
...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
},
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
);
const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
// By id first: a target with several tweens (two `to`s on the same selector)
// matches the selector lookup on whichever one happens to be first, and the
// extend-and-add below would then rewrite a tween the drag never touched.
const converted =
fresh.find((a) => a.id === anim.id && a.keyframes) ??
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ??
anim;
const convertedStart = resolveTweenStart(converted) ?? ts;
const convertedDur = resolveTweenDuration(converted) || td;
await extendTweenAndAddKeyframe(
selection,
converted,
properties,
ct,
convertedStart,
convertedDur,
callbacks,
beforeReload,
);
return;
}
const coalesceKey = `gsap:convert-drag:${anim.id}`;
// fallow-ignore-next-line code-duplication
await callbacks.commitMutation(
selection,
{
type: "convert-to-keyframes",
animationId: anim.id,
...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
},
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
);
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
const editedSelected = activeKeyframePct != null;
if (editedSelected) setActiveKeyframePct(null);
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
...(backfillDefaults ? { backfillDefaults } : {}),
},
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
);
if (editedSelected) parkPlayheadOnKeyframe(anim, pct);
const plan = planValueEdit(selection, anim, { x: newX, y: newY }, iframe, {
backfill: { x: baseGsapX, y: baseGsapY },
});
return plan.ok
? { status: "persisted" }
: { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
}
// fallow-ignore-next-line code-duplication
@@ -279,9 +68,8 @@ export async function commitGsapPositionFromDrag(
studioOffset: { x: number; y: number },
gsapPos: { x: number; y: number },
iframe: HTMLIFrameElement | null,
selector: string,
callbacks: GsapDragCommitCallbacks,
): Promise<void> {
): Promise<GsapEditOutcome> {
const el = selection.element;
// fallow-ignore-next-line code-duplication
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
@@ -298,8 +86,6 @@ export async function commitGsapPositionFromDrag(
el.removeAttribute("data-hf-drag-initial-offset-y");
};
const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
const ct = usePlayerStore.getState().currentTime;
if (anim.arcPath?.enabled) {
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
@@ -324,14 +110,15 @@ export async function commitGsapPositionFromDrag(
);
setActiveKeyframePct(null);
parkPlayheadOnKeyframe(anim, pct);
return;
return { status: "persisted" };
}
const tweenStart = resolveTweenStart(anim);
// Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
// default here would collapse a duration-less arc's window on drag.
const tweenDuration = resolveEditableTweenDuration(anim, selection);
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2)
return { status: "persisted" };
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
await callbacks.commitMutation(
selection,
@@ -351,161 +138,11 @@ export async function commitGsapPositionFromDrag(
beforeReload: restoreOffset,
},
);
return;
}
if (anim.keyframes) {
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
const dragProps: Record<string, number> = { x: newX, y: newY };
const ts = resolveTweenStart(effectiveAnim);
const td = resolveEditableTweenDuration(effectiveAnim, selection);
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
if (outsideRange && !hasSelectedKeyframe) {
await extendTweenAndAddKeyframe(
selection,
effectiveAnim,
dragProps,
ct,
ts,
td,
callbacks,
restoreOffset,
backfillDefaults,
);
} else {
await commitKeyframedPosition(
selection,
effectiveAnim,
dragProps,
callbacks,
restoreOffset,
backfillDefaults,
);
}
} else if (anim.method === "from" || anim.method === "fromTo") {
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = resolveEditableTweenDuration(anim, selection);
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
const outsideRange =
!hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
const dragProps: Record<string, number> = { x: newX, y: newY };
if (outsideRange && ts !== null) {
await callbacks.commitMutation(
selection,
{ type: "split-into-property-groups", animationId: anim.id },
{ label: "Split from() for drag", skipReload: true },
);
const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
const existingPosAnim = allAnims.find(
(a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
);
if (existingPosAnim?.keyframes) {
const posTs = resolveTweenStart(existingPosAnim);
const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
if (posTs !== null) {
await extendTweenAndAddKeyframe(
selection,
existingPosAnim,
{ x: newX, y: newY },
ct,
posTs,
posTd,
callbacks,
restoreOffset,
backfillDefaults,
);
return;
}
}
const newStart = Math.min(ct, ts);
const newEnd = Math.max(ct, ts + td);
const newDuration = Math.max(0.01, newEnd - newStart);
const dragBefore = ct < ts;
const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
[];
if (dragBefore) {
keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
if (origStartPct > 0.5 && origStartPct < 99.5) {
keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
}
keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
} else {
keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
if (origEndPct > 0.5 && origEndPct < 99.5) {
keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
}
keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
}
keyframes.sort((a, b) => a.percentage - b.percentage);
const baseKf = {
targetSelector: anim.targetSelector,
position: roundTo3(newStart),
duration: roundTo3(newDuration),
keyframes,
};
await callbacks.commitMutation(
selection,
existingPosAnim
? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf }
: { type: "add-with-keyframes", ...baseKf },
{
label: "Move layer (from extended)",
keyframeAction: "add",
softReload: true,
beforeReload: restoreOffset,
},
);
} else {
const coalesceKey = `gsap:convert-drag:${anim.id}`;
await callbacks.commitMutation(
selection,
{
type: "convert-to-keyframes",
animationId: anim.id,
},
{ label: "Convert from() for drag", skipReload: true, coalesceKey },
);
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
if (activeKeyframePct != null) setActiveKeyframePct(null);
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties: dragProps,
...(backfillDefaults ? { backfillDefaults } : {}),
},
{
label: `Move layer (keyframe ${pct}%)`,
softReload: true,
beforeReload: restoreOffset,
coalesceKey,
},
);
}
} else {
await commitFlatViaKeyframes(
selection,
anim,
{ x: newX, y: newY },
callbacks,
restoreOffset,
iframe,
selector,
backfillDefaults,
);
return { status: "persisted" };
}
return commitValueAtPlayhead(selection, anim, { x: newX, y: newY }, iframe, callbacks, {
label: "Move layer",
backfill: { x: baseGsapX, y: baseGsapY },
beforeReload: restoreOffset,
});
}
+22 -2
View File
@@ -1,6 +1,11 @@
import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser";
export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
export type GsapEditBlockReason =
| "no-selector"
| "unroll-required"
| "source-uneditable"
| "keyframes-uneditable"
| "mixed-files";
/**
* Which of the nine situations produced a block. The user-facing `reason` stays
@@ -21,7 +26,18 @@ export type GsapEditBlockDetail =
| "no-position-tween"
| "live-rotation-no-source-tween"
| "live-resize-no-source-tween"
| "zero-duration-tween";
| "zero-duration-tween"
| PlayheadEditRefusal;
/** Why an edit at the playhead could not be written as keyframes of the file's tween. */
export type PlayheadEditRefusal =
| "eased-keyframes"
| "simple-array-keyframes"
| "unknown-ease"
| "implicit-end-unknown"
| "not-a-tween"
| "no-timing"
| "shared-tween";
export type GsapEditOutcome =
| {
@@ -52,6 +68,10 @@ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
"unroll-required":
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
"source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.",
"keyframes-uneditable":
"Studio can't add this edit to the animation's keyframes. Edit this animation in the Code tab.",
"mixed-files":
"These layers are animated in different files. Move each file's layers separately.",
};
export class GsapEditBlockedError extends Error {
@@ -0,0 +1,34 @@
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { GsapEditBlockedError } from "./gsapEditOutcome";
import { gsapWriteFile } from "./gsapScriptCommitTypes";
/** The first member a group's preflight refused, else one from a second file. */
export function firstPreflightFailure(
results: PromiseSettledResult<void>[],
updates: DomEditGroupPathOffsetCommit[],
savedOnElement: Map<DomEditSelection, boolean>,
activeCompPath: string | null | undefined,
): { error: unknown; selection: DomEditSelection } | null {
for (const [index, result] of results.entries()) {
if (result.status !== "rejected") continue;
const selection = updates[index]?.selection;
if (selection) return { error: result.reason, selection };
}
return secondFile(updates, savedOnElement, activeCompPath);
}
/** The group's script writes go out as one batch to one file, so members from two files refuse. */
function secondFile(
updates: DomEditGroupPathOffsetCommit[],
savedOnElement: Map<DomEditSelection, boolean>,
activeCompPath: string | null | undefined,
): { error: unknown; selection: DomEditSelection } | null {
const scripted = updates.filter(({ selection }) => !savedOnElement.has(selection));
const fileOf = (s: DomEditSelection) => gsapWriteFile(s, activeCompPath);
const file = scripted[0] && fileOf(scripted[0].selection);
const other = scripted.find(({ selection }) => fileOf(selection) !== file);
return other
? { error: new GsapEditBlockedError("mixed-files"), selection: other.selection }
: null;
}
@@ -1,11 +1,12 @@
import { studioWriteHeaders } from "../utils/studioFileVersion";
import { studioApiFetch } from "../utils/studioApiFetch";
export class GsapPreviewConvergenceError extends Error {}
export class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
/** Verify rollback ownership support before any GSAP mutation can land. */
export async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
const response = await fetch(
const response = await studioApiFetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
);
if (!response.ok) {
@@ -29,7 +30,7 @@ export async function rollbackOwnedMutation(
if (targetPath.includes("\0") || targetPath.includes("..")) {
throw new Error(`Unsafe path: ${targetPath}`);
}
const response = await fetch(
const response = await studioApiFetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
{
method: "POST",
@@ -95,7 +96,7 @@ export async function postGsapMutation(
): Promise<GsapMutationStatus> {
let response: Response;
try {
response = await fetch(
response = await studioApiFetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
@@ -0,0 +1,75 @@
import { vi } from "vitest";
import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
/** A property's `[start, end]` as GSAP parsed it from the file when the tween first rendered. */
type Ends = Record<string, [number, number]>;
/** A GSAP 3 tween as the preview runtime holds it, with the property tweens the parse readers walk. */
export function liveTween(
target: Element,
tween: { start: number; duration: number; vars: Record<string, unknown>; ends?: Ends },
{ from = false, parts }: { from?: boolean; parts?: unknown[] } = {},
) {
let head: Record<string, unknown> | undefined;
for (const [p, [s, e]] of Object.entries(tween.ends ?? {}).reverse())
head = from ? { p, s: e, c: s - e, _next: head } : { p, s, c: e - s, _next: head };
return {
targets: () => [target],
startTime: () => tween.start,
duration: () => tween.duration,
vars: tween.vars,
_from: from,
...(head && { _pt: { d: { _pt: head } } }),
...(parts && { timeline: { getChildren: () => parts } }),
};
}
/** A preview iframe whose one timeline holds `tweens`, answering `getProperty` from `live`. */
export function previewWith(
element: Element,
tweens: unknown[],
live: Record<string, number> = {},
): HTMLIFrameElement {
return {
contentWindow: {
__timelines: { main: { getChildren: () => tweens, duration: () => 10 } },
gsap: {
getProperty: (_el: Element, prop: string) => live[prop] ?? 0,
defaults: () => ({ ease: "power1.out" }),
},
},
contentDocument: element.ownerDocument,
} as unknown as HTMLIFrameElement;
}
export const tween = (fields: Partial<GsapAnimation>): GsapAnimation =>
({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation;
/** A `to` tween on `#el` from 0, grouped as the parser would; duration 0 is an immediate-render hold. */
export function elTween(
id: string,
properties: Record<string, number>,
duration: number,
ease?: string,
): GsapAnimation {
return {
id,
targetSelector: "#el",
propertyGroup: classifyTweenPropertyGroup(properties),
method: "to",
properties,
position: 0,
resolvedStart: 0,
duration,
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : ease && { ease }),
} as unknown as GsapAnimation;
}
/** Per-test cleanup for the intercept sweeps: mocks, the playhead store and the document. */
export function resetGsapEditState(): void {
vi.restoreAllMocks();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
document.body.innerHTML = "";
}
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { gsap } from "gsap";
import { afterEach, describe, expect, it } from "vitest";
import { findParsedTween, parsedImplicitEndValue } from "./gsapParsedTween";
import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
type Parsed = Parameters<typeof parsedImplicitEndValue>[0];
const el = document.createElement("div");
const part = (start: number, ends?: Record<string, [number, number]>) =>
liveTween(el, { start, duration: 1, vars: { x: 0 }, ends });
const keyframed = (parts: unknown[]) =>
liveTween(el, { start: 0, duration: 2, vars: { keyframes: [] } }, { parts }) as Parsed;
describe("parsedImplicitEndValue", () => {
it("reads a flat tween's own start and end", () => {
const value = parsedImplicitEndValue(part(0, { x: [-60, 0] }) as Parsed);
expect([value("x", "start"), value("x", "end")]).toEqual([-60, 0]);
});
it("reads a keyframed tween's start from its first part and its end from its last", () => {
const value = parsedImplicitEndValue(
keyframed([part(0, { x: [0, 50] }), part(1, { x: [50, 120] })]),
);
expect([value("x", "start"), value("x", "end")]).toEqual([0, 120]);
});
it("refuses when the part nearest that end is not initialised, rather than read an earlier one", () => {
const value = parsedImplicitEndValue(keyframed([part(0, { x: [0, 50] }), part(1)]));
expect(value("x", "end")).toBeNull();
});
it("has no value without a parsed tween", () => {
expect(parsedImplicitEndValue(null)("x", "start")).toBeNull();
});
});
describe("findParsedTween", () => {
afterEach(() => document.body.replaceChildren());
it("takes the flat tween an edit names, not a keyframed tween at the same start", () => {
const fade = liveTween(el, { start: 0, duration: 2, vars: { keyframes: {}, ease: "none" } });
const slide = liveTween(el, { start: 0, duration: 2, vars: { x: 300 } });
const edit = tween({ method: "to", properties: { x: 300 }, resolvedStart: 0, duration: 2 });
expect(findParsedTween(previewWith(el, [fade, slide]), el, edit)).toBe(slide);
});
it("parses a to() tween the playhead has not reached, from its authored start", () => {
const box = document.body.appendChild(document.createElement("div"));
box.id = "box";
const timeline = gsap.timeline({ paused: true }).to(box, { x: 300, duration: 1 }, 2);
timeline.seek(0.5);
gsap.set(box, { x: 40 });
const iframe = { contentWindow: { __timelines: { main: timeline }, gsap } };
const edit = tween({ method: "to", properties: { x: 300 }, resolvedStart: 2, duration: 1 });
const parsed = findParsedTween(iframe as unknown as HTMLIFrameElement, box, edit);
expect(parsedImplicitEndValue(parsed)("x", "start")).toBe(0);
expect([gsap.getProperty(box, "x"), timeline.time()]).toEqual([40, 0.5]);
timeline.kill();
});
});
@@ -0,0 +1,243 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { elementTargets } from "../utils/elementGsap";
import { resolveTweenStart } from "../utils/globalTimeCompiler";
import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
// GSAP 3 internals: a property tween in a tween's `_pt` chain; CSSPlugin keeps its own under `d._pt`.
interface PropTween {
p?: string;
s?: number;
c?: number;
d?: { _pt?: PropTween };
_next?: PropTween;
}
interface ParsedTween {
_pt?: PropTween;
_from?: boolean;
_initted?: boolean;
vars?: Record<string, unknown>;
parent?: {
vars?: { defaults?: { ease?: unknown } };
time?: () => number;
seek?: (time: number, suppressEvents?: boolean) => unknown;
getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => ParsedTween[];
};
timeline?: { getChildren?: () => ParsedTween[] };
targets?: () => unknown[];
startTime?: () => number;
duration?: () => number;
}
interface GsapWindow {
gsap?: {
defaults?: () => { ease?: unknown };
getProperty?: (target: Element, prop: string) => unknown;
set?: (target: Element, vars: Record<string, unknown>) => void;
parseEase?: (name: string) => unknown;
};
__timelines?: Record<string, { getChildren?: (nested: boolean) => ParsedTween[] }>;
}
// `scale` parses into the two longhands, which always share a start and end for a `scale` tween.
const PARSED_NAME: Record<string, string> = { scale: "scaleX", rotate: "rotation" };
function findPropTween(pt: PropTween | undefined, prop: string): PropTween | null {
for (let node = pt; node; node = node._next) {
if (node.p === prop && typeof node.s === "number" && typeof node.c === "number") return node;
const nested = findPropTween(node.d?._pt, prop);
if (nested) return nested;
}
return null;
}
/** `[start, end]` of `prop` in one initialised tween; a from() tween runs its pair backwards. */
function endsIn(tween: ParsedTween, prop: string): [number, number] | null {
const pt = findPropTween(tween._pt, PARSED_NAME[prop] ?? prop);
if (!pt) return null;
const pair: [number, number] = [pt.s!, pt.s! + pt.c!];
return tween._from ? [pair[1], pair[0]] : pair;
}
/** The live tween GSAP built from `anim`: same element, start and channels, parsed. */
// fallow-ignore-next-line complexity
export function findParsedTween(
iframe: HTMLIFrameElement | null,
element: Element,
anim: GsapAnimation,
): ParsedTween | null {
const win = iframe?.contentWindow as GsapWindow | null;
const start = resolveTweenStart(anim);
if (!win?.__timelines || start == null) return null;
const steps = anim.keyframes?.keyframes.map((kf) => kf.properties) ?? [anim.properties];
const props = [...new Set(steps.flatMap((step) => Object.keys(step)))];
const keyframed = Boolean(anim.keyframes);
for (const timeline of Object.values(win.__timelines)) {
for (const tween of timeline?.getChildren?.(true) ?? []) {
const targets = (tween.targets?.() ?? []) as Array<{ id?: unknown }>;
if (!targets.includes(element) && !targets.some((t) => element.id && t.id === element.id))
continue;
if (!startsAt(tween, start)) continue;
const vars = tween.vars ?? {};
const carries = keyframed ? "keyframes" in vars : props.some((p) => p in vars);
if (!carries || !((tween.duration?.() ?? 0) > 0)) continue;
parseUnplayed(win, element, tween, props);
return tween;
}
}
return null;
}
/** GSAP's startTime() counts the tween's `delay`; the file's parse does not. */
function startsAt(tween: ParsedTween, start: number): boolean {
const delay = Number(tween.vars?.delay) || 0;
return Math.abs((tween.startTime?.() ?? Number.NaN) - delay - start) <= 1e-3;
}
/** `anim` timed as GSAP runs it: its start with any delay, a duration it leaves to GSAP filled in. */
export function withLiveTiming(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
const start = tween?.startTime?.();
return {
...anim,
...(Number.isFinite(start) && { resolvedStart: start }),
...(anim.duration == null && tween && { duration: tween.duration?.() }),
};
}
/** GSAP parses a to() tween only when the playhead first passes it, and a soft reload resets that.
* Play its timeline from the tween's start (so earlier tweens set its start value) to its end and
* back, its channels cleared first; then restore every styled element's attributes and GSAP cache. */
function parseUnplayed(win: GsapWindow, element: Element, tween: ParsedTween, props: string[]) {
const seek = !isParsed(tween) && seekable(win, tween);
if (!seek) return;
const { parent, gsap } = seek;
const before = layerStates(tween, element);
const now = parent.time();
const start = tween.startTime?.() ?? 0;
gsap.set(element, { clearProps: props.join(",") });
// Re-read now, so GSAP rebuilds its transform cache from the cleared element before the seek.
for (const p of props) gsap.getProperty(element, p);
try {
parent.seek(start, true);
parent.seek(start + (tween.duration?.() ?? 0), true);
} finally {
parent.seek(now, true);
restore(before);
}
}
function isParsed(tween: ParsedTween): boolean {
const parts = tween.timeline?.getChildren?.() ?? [];
return Boolean(tween._initted) && parts.every((part) => part._initted);
}
type GsapTools = {
getProperty: (el: Element, p: string) => unknown;
set: (el: Element, v: Record<string, unknown>) => void;
};
function seekable(win: GsapWindow, tween: ParsedTween) {
const { parent } = tween;
const gsap = win.gsap;
if (!parent?.seek || !parent.time || !gsap?.getProperty || !gsap.set) return null;
return {
parent: { seek: parent.seek.bind(parent), time: parent.time.bind(parent) },
gsap: { getProperty: gsap.getProperty.bind(gsap), set: gsap.set.bind(gsap) } as GsapTools,
};
}
type Layer = Element & { _gsap?: Record<string, unknown> };
function layerStates(tween: ParsedTween, element: Element) {
const layers = new Set<Layer>([element]);
for (const child of tween.parent?.getChildren?.(true, true, false) ?? [])
for (const target of elementTargets(child)) layers.add(target);
return [...layers].map((layer) => ({
layer,
attributes: new Map([...layer.attributes].map((a) => [a.name, a.value])),
cache: layer._gsap && { ...layer._gsap },
}));
}
function restore(states: ReturnType<typeof layerStates>) {
for (const { layer, attributes, cache } of states) {
restoreAttributes(layer, attributes);
if (cache && layer._gsap) restoreCache(layer._gsap, cache);
}
}
function restoreAttributes(layer: Element, attributes: Map<string, string>) {
for (const { name } of [...layer.attributes])
if (!attributes.has(name)) layer.removeAttribute(name);
for (const [name, value] of attributes)
if (layer.getAttribute(name) !== value) layer.setAttribute(name, value);
}
function restoreCache(live: Record<string, unknown>, cache: Record<string, unknown>) {
for (const key of Object.keys(live)) if (!(key in cache)) delete live[key];
Object.assign(live, cache);
}
/** Start and end values from GSAP's own parse of the tween, as loaded from the file. Null when
* GSAP has not initialised that part of the tween yet: the caller refuses rather than guess. */
export function parsedImplicitEndValue(tween: ParsedTween | null): ImplicitEndValue {
return (prop, end) => {
if (!tween) return null;
for (const part of partsFrom(tween, end)) {
const pair = endsIn(part, prop);
if (pair) return end === "start" ? pair[0] : pair[1];
// The nearest part animating `prop` is not initialised: an earlier one would be a wrong value.
if (prop in (part.vars ?? {})) return null;
}
return null;
};
}
/** A keyframed tween's parts, nearest `end` first; a flat tween is its own one part. */
function partsFrom(tween: ParsedTween, end: "start" | "end"): ParsedTween[] {
const parts = tween.timeline?.getChildren?.() ?? [];
if (parts.length === 0) return [tween];
return end === "start" ? parts : [...parts].reverse();
}
/** The ease GSAP resolved for a flat tween that authors none: its timeline's default, else GSAP's. */
export function parsedTweenEase(iframe: HTMLIFrameElement | null, tween: ParsedTween | null) {
if (!tween) return null;
const win = iframe?.contentWindow as GsapWindow | null;
const ease =
tween.vars?.ease ?? tween.parent?.vars?.defaults?.ease ?? win?.gsap?.defaults?.().ease;
if (typeof ease === "string") return ease;
const named = (name: string) => win?.gsap?.parseEase?.(name) === ease;
return typeof ease === "function" ? (BUILT_IN_EASES.find(named) ?? null) : null;
}
// GSAP holds a resolved ease, its own default included, as the function parseEase returns.
const BUILT_IN_EASES = [
"none",
...[
"power1",
"power2",
"power3",
"power4",
"sine",
"expo",
"circ",
"back",
"elastic",
"bounce",
].flatMap((e) => [`${e}.in`, `${e}.out`, `${e}.inOut`]),
];
/** An array of keyframe steps at the exact percentages GSAP times them; the parse rounds them. */
export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
const data = anim.keyframes;
const parts = tween?.timeline?.getChildren?.() ?? [];
const total = tween?.duration?.() ?? 0;
if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0))
return anim;
const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total);
if (ends.some((end) => !Number.isFinite(end))) return anim;
const keyframes = data.keyframes.map((kf, i) => ({
...kf,
percentage: Math.round(ends[i]! * 100000) / 1000,
}));
return { ...anim, keyframes: { ...data, keyframes } };
}
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { tween } from "./gsapParsedTween.test-helpers";
import { findGsapPositionAnimation, pickClosestToPlayhead } from "./gsapPositionDetection";
afterEach(() => usePlayerStore.setState({ currentTime: 0 }));
const slide = (
id: string,
resolvedStart: number,
duration: number,
fields: Partial<GsapAnimation> = {},
) => tween({ id, method: "to", properties: { x: 10 }, resolvedStart, duration, ...fields });
const keyed = (...percentages: number[]) => ({
keyframes: {
format: "object-array" as const,
keyframes: percentages.map((percentage) => ({ percentage, properties: { x: percentage } })),
},
});
describe("pickClosestToPlayhead", () => {
it.each([
["the one spanning the playhead", 1.5, [slide("a", 0, 1), slide("b", 1, 1)], "b"],
["the nearer end when none spans it", 5, [slide("a", 0, 1), slide("b", 2, 2)], "b"],
[
"the one whose file states the playhead's value",
1,
[slide("a", 0, 1), slide("b", 1, 1)],
"a",
],
[
"the later one when both state it",
1,
[slide("a", 0, 2, keyed(0, 50, 100)), slide("b", 0.5, 1, keyed(0, 50, 100))],
"b",
],
[
"the one with more keyframes on a tie",
5,
[slide("a", 0, 1, keyed(0, 100)), slide("b", 0, 1, keyed(0, 50, 100))],
"b",
],
])("picks %s", (_, time, anims, id) => {
usePlayerStore.setState({ currentTime: time });
expect(pickClosestToPlayhead(anims)?.id).toBe(id);
});
});
describe("findGsapPositionAnimation", () => {
it.each([
[
"the tween spanning the playhead",
1.5,
undefined,
[slide("a", 0, 1, keyed(0, 100)), slide("b", 1, 1)],
"b",
],
["the nearer tween", 9, undefined, [slide("a", 0, 1), slide("b", 6, 1)], "b"],
[
"the selected target's tween",
0.5,
"#box",
[slide("a", 0, 1, { targetSelector: "#card", ...keyed(0, 100) }), slide("b", 0, 1)],
"b",
],
[
"a tween on one target over a shared one",
0.5,
undefined,
[slide("a", 0, 1, { targetSelector: "#box, #card" }), slide("b", 0, 1)],
"b",
],
])("prefers %s", (_, time, selector, anims, id) => {
usePlayerStore.setState({ currentTime: time });
expect(findGsapPositionAnimation(anims, selector)?.id).toBe(id);
});
});
@@ -7,7 +7,7 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { getIframeGsap, queryIframeElement } from "./gsapShared";
import { getIframeGsap, KEYFRAME_PCT_MATCH, queryIframeElement } from "./gsapShared";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
// fallow-ignore-next-line complexity
@@ -47,52 +47,71 @@ export function findGsapPositionAnimation(
if (animations.length === 0) return null;
const currentTime = usePlayerStore.getState().currentTime;
const scored = animations
.filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
.map((a) => {
let score = 0;
if (animHasPosition(a)) score += 10;
if (a.keyframes) score += 5;
if (selector && a.targetSelector === selector) score += 8;
else if (a.targetSelector.includes(",")) score -= 5;
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
const dur = a.duration ?? 0;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
else
score -= Math.round(
Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
);
return { anim: a, score };
});
const scored = animations.filter(animHasPosition).map((a) => {
let score = 0;
if (a.keyframes) score += 5;
if (selector && a.targetSelector === selector) score += 8;
else if (a.targetSelector.includes(",")) score -= 5;
return { anim: a, score: score + playheadProximity(a, currentTime) };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]?.anim ?? animations[0];
return scored[0]?.anim ?? null;
}
function playheadProximity(a: GsapAnimation, currentTime: number): number {
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
const dur = a.duration ?? 0;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) return 50;
return -Math.round(Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5);
}
/** Whether the file states `anim`'s value at `time`; a tween's implicit start is GSAP's current value. */
function statesValueAt(anim: GsapAnimation, time: number): boolean {
const duration = resolveTweenDuration(anim);
if (!(duration > 0)) return false;
const pct = ((time - (resolveTweenStart(anim) ?? 0)) / duration) * 100;
const near = (keyframe: number) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH;
if (anim.keyframes) return anim.keyframes.keyframes.some((kf) => near(kf.percentage));
if (anim.method === "from") return near(0);
return near(100) || (anim.method === "fromTo" && near(0));
}
/**
* From a set of candidate tweens, pick the one whose time range is closest to
* the current playhead. A tween that *contains* the playhead wins outright;
* otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
* extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
* gesture-recorded tween beats a stub when both are equidistant).
* extends) the 4s tween, not the 1.5s one. Ties: the one whose file states a value at the
* playhead (the later one, which renders, if both do), then most keyframes.
*/
// fallow-ignore-next-line complexity
export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
if (anims.length <= 1) return anims[0] ?? null;
const ct = usePlayerStore.getState().currentTime;
return anims.reduce((best, a) => {
const s = resolveTweenStart(a) ?? 0;
const e = s + resolveTweenDuration(a);
const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
const bestS = resolveTweenStart(best) ?? 0;
const bestE = bestS + resolveTweenDuration(best);
const bestDist =
ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
const dist = playheadDistance(a, ct);
const bestDist = playheadDistance(best, ct);
if (dist < bestDist) return a;
if (
dist === bestDist &&
(a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
)
return a;
const stated = dist === 0 && bestDist === 0 ? statedValueWinner(a, best, ct) : null;
if (stated) return stated;
if (dist === bestDist && keyframeCount(a) > keyframeCount(best)) return a;
return best;
});
}
function playheadDistance(anim: GsapAnimation, time: number): number {
const s = resolveTweenStart(anim) ?? 0;
const e = s + resolveTweenDuration(anim);
return time >= s && time <= e ? 0 : Math.min(Math.abs(time - s), Math.abs(time - e));
}
function statedValueWinner(a: GsapAnimation, b: GsapAnimation, time: number) {
const [mine, theirs] = [statesValueAt(a, time), statesValueAt(b, time)];
if (mine !== theirs) return mine ? a : b;
const [s, bS] = [resolveTweenStart(a) ?? 0, resolveTweenStart(b) ?? 0];
if (mine && s !== bS) return s > bS ? a : b;
return null;
}
function keyframeCount(anim: GsapAnimation): number {
return anim.keyframes?.keyframes.length ?? 0;
}
@@ -148,17 +148,18 @@ function positionAnimation(
} as unknown as GsapAnimation;
}
/** Every x/y a run wrote, in commit order. */
/** Every x/y a run wrote AT THE PLAYHEAD (percentage 0 here), in commit order. */
function persistedPositions(calls: unknown[][]): Array<{ x?: number; y?: number }> {
const written: Array<{ x?: number; y?: number }> = [];
for (const call of calls) {
const mutation = call[1] as {
properties?: Record<string, number>;
keyframes?: Array<{ properties: Record<string, number> }>;
keyframes?: Array<{ percentage: number; properties: Record<string, number> }>;
x?: number;
y?: number;
};
const sources = [mutation.properties, ...(mutation.keyframes ?? []).map((k) => k.properties)];
const atPlayhead = (mutation.keyframes ?? []).filter((k) => k.percentage === 0);
const sources = [mutation.properties, ...atPlayhead.map((k) => k.properties)];
if (mutation.x != null || mutation.y != null) written.push({ x: mutation.x, y: mutation.y });
for (const source of sources) {
if (source && (source.x != null || source.y != null))
@@ -37,6 +37,7 @@ const widthTween = {
position: 0,
resolvedStart: 0,
duration: 4,
ease: "none",
} as unknown as GsapAnimation;
function mount(animations: GsapAnimation[]) {
@@ -23,17 +23,13 @@
* the way through. A sweep across rotations is what tells us it is.
*/
import { afterEach, expect, it, vi } from "vitest";
import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { elTween, resetGsapEditState } from "./gsapParsedTween.test-helpers";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
afterEach(() => {
vi.restoreAllMocks();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
document.body.innerHTML = "";
});
afterEach(resetGsapEditState);
const LAYOUT = { left: 120, top: 520 };
@@ -57,20 +53,6 @@ function renderRect(pose: Pose, rotationDeg: number) {
type Props = Record<string, number>;
function tween(id: string, properties: Props, duration: number): GsapAnimation {
return {
id,
targetSelector: "#el",
propertyGroup: classifyTweenPropertyGroup(properties),
method: "to",
properties,
position: 0,
resolvedStart: 0,
duration,
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
} as unknown as GsapAnimation;
}
interface Case {
name: string;
/** The element's untransformed CSS box. */
@@ -99,18 +81,24 @@ const ROTATIONS = [0, -8, 45, -47, 90, 180];
* the size routes here however it looks from the animation list.
*/
const ROUTES = {
"scale tween": { animations: () => [tween("#el-scale", { scale: 1 }, 2)], settles: true },
"scale longhands": {
animations: () => [tween("#el-scale", { scaleX: 1, scaleY: 1 }, 2)],
"scale tween": {
animations: () => [elTween("#el-scale", { scale: 1 }, 2, "none")],
settles: true,
},
"scale instant hold": { animations: () => [tween("#el-scale", { scale: 1 }, 0)], settles: false },
"scale longhands": {
animations: () => [elTween("#el-scale", { scaleX: 1, scaleY: 1 }, 2, "none")],
settles: true,
},
"scale instant hold": {
animations: () => [elTween("#el-scale", { scale: 1 }, 0, "none")],
settles: false,
},
"size tween": {
animations: () => [tween("#el-size", { width: 630, height: 408 }, 2)],
animations: () => [elTween("#el-size", { width: 630, height: 408 }, 2, "none")],
settles: false,
},
"size instant hold": {
animations: () => [tween("#el-size", { width: 630, height: 408 }, 0)],
animations: () => [elTween("#el-size", { width: 630, height: 408 }, 0, "none")],
settles: false,
},
} as const;
@@ -7,7 +7,8 @@ import { observeGsapGesture } from "./gsapGestureOutcome";
import { trackStudioEvent } from "../utils/studioTelemetry";
import type { CommitMutation } from "./gsapScriptCommitTypes";
import { computeCurrentPercentage } from "./gsapDragCommit";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
import { commitSizeAtPlayhead, tryGsapResizeIntercept } from "./gsapResizeIntercept";
import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
@@ -15,6 +16,8 @@ afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
// One #clip per test: leftovers would make every tween on it look shared.
document.body.innerHTML = "";
});
/**
@@ -42,10 +45,18 @@ function fakeIframe(el: HTMLElement, gsapValues: Record<string, number>) {
// The element's OPACITY intro tween lives on the timeline: unfiltered
// capture would pick `opacity` up via the other-tween sweep.
const opacityIntro = { targets: () => [el], vars: { opacity: 0, duration: 0.8 } };
const scaleIntro = liveTween(
el,
{ start: 0.2, duration: 0.8, vars: { scale: 0.9 }, ends: { scaleX: [0.9, 1] } },
{ from: true },
);
return {
contentWindow: {
__timelines: { main: { getChildren: () => [opacityIntro] } },
gsap: { getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0 },
__timelines: { main: { getChildren: () => [opacityIntro, scaleIntro] } },
gsap: {
getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0,
defaults: () => ({ ease: "power1.out" }),
},
},
contentDocument: document,
} as unknown as HTMLIFrameElement;
@@ -295,24 +306,26 @@ async function runResize(
return committed;
}
it("scale-route resize converts via the group filter and commits scale, not width/height", async () => {
it("scale-route resize keyframes scale through the group filter, not width/height", async () => {
const el = makeGradedElement();
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0, rotation: 0 });
// uniform: 800/640 === 450/360
const committed = await runResize(el, iframe, { width: 800, height: 450 });
const convert = committed.find((m) => m.type === "convert-to-keyframes");
expect(convert).toBeDefined();
const fromValues = convert!.resolvedFromValues as Record<string, number>;
// Group filter: the opacity intro tween must NOT leak into the conversion.
expect(fromValues).not.toHaveProperty("opacity");
expect(fromValues).toHaveProperty("scale");
const keyframes = committed.find((m) => m.type === "replace-with-keyframes")?.keyframes as
| Array<{ properties: Record<string, number> }>
| undefined;
// Group filter: the opacity intro tween must NOT leak into the scale keyframes.
expect(keyframes?.some((k) => "opacity" in k.properties)).toBe(false);
expect(keyframes?.some((k) => "scale" in k.properties)).toBe(true);
// Every committed property is scale-group — the resize never writes
// width/height for a scale-driven element (the double-apply bug class).
const allProps = committed.flatMap((m) => [
...Object.keys((m.properties as Record<string, unknown>) ?? {}),
...Object.keys((m.resolvedFromValues as Record<string, unknown>) ?? {}),
...((m.keyframes as Array<{ properties: object }>) ?? []).flatMap((k) =>
Object.keys(k.properties),
),
]);
expect(allProps).not.toContain("width");
expect(allProps).not.toContain("height");
@@ -560,6 +573,45 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
expect(commitMutation).not.toHaveBeenCalled();
});
it.each([
["stays put", { x: 0, y: 0 }, { width: 300 }, undefined],
["moves", { x: -40, y: 0 }, { width: 300, x: 10, y: 0 }, true],
])(
"a size written at the playhead carries the anchor only when the box %s",
async (_, offset, written, owns) => {
const selection = titleSelection();
const el = selection.element;
el.setAttribute("data-hf-drag-gsap-base-x", "50");
el.setAttribute("data-hf-drag-gsap-base-y", "0");
const slide = tween({
id: "#title-to-0",
targetSelector: "#title",
propertyGroup: undefined,
method: "to",
properties: { x: 100, width: 320 },
resolvedStart: 0,
duration: 2,
ease: "none",
});
usePlayerStore.setState({ currentTime: 1 });
const commitMutation = vi.fn();
const live = liveTween(el, { start: 0, duration: 2, vars: slide.properties });
const outcome = await commitSizeAtPlayhead(
selection,
slide,
{ width: 300 },
previewWith(el, [live]),
offset,
{ commitMutation },
);
expect(outcome.ownsDragOffset).toBe(owns);
const mutation = commitMutation.mock.calls[0]![1];
expect(mutation.keyframes[0].properties).toEqual(written);
},
);
it.each([
[5, 1],
[0.5, 1],
+79 -169
View File
@@ -14,43 +14,34 @@ import {
} from "../components/editor/manualEditsTypes";
import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import { readGsapProperty } from "./gsapRuntimeReaders";
import {
commitStaticGsapPosition,
commitStaticGsapSize,
commitKeyframedSizeFromResize,
computeCurrentPercentage,
findExistingPositionWrite,
findSizeSetAnimation,
materializeIfDynamic,
} from "./gsapDragCommit";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import { computeDraggedGsapPosition, restoreDragOffset } from "./draggedGsapPosition";
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
import { resolveGroupTween } from "./gsapRuntimeBridge";
import { logResize } from "../utils/resizeDebug";
import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome";
import {
animationWritesAnyProperty,
assertGsapEditPersisted,
type GsapEditOutcome,
} from "./gsapEditOutcome";
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
const SIZE_PROPS = new Set(["width", "height"]);
/** Build identity (zero / one) values for each property in `source`. */
function synthesizeIdentityProps(
source: Record<string, number | string>,
): Record<string, number | string> {
const id: Record<string, number | string> = {};
for (const [k, v] of Object.entries(source)) {
if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
else id[k] = v;
}
return id;
}
const POSITION_XY = new Set(["x", "y"]);
/**
* The element's box before the resize draft ran, in CSS pixels.
@@ -72,6 +63,51 @@ function originalBoxSize(
return Number.isFinite(inline) && inline > 0 ? inline : null;
}
/** The box's width and height before the draft, for a size tween that animates only one. */
function preGestureBoxSize(el: HTMLElement): Record<string, number> {
const width = originalBoxSize(el, STUDIO_ORIGINAL_BOX_WIDTH_ATTR, "width");
const height = originalBoxSize(el, STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, "height");
return { ...(width != null && { width }), ...(height != null && { height }) };
}
/** A size write at the playhead. When the same tween animates position, the resize's anchor move
* goes into this one write: a second write to the tween in the same gesture would plan on stale ids. */
export async function commitSizeAtPlayhead(
selection: DomEditSelection,
anim: GsapAnimation,
size: Record<string, number>,
iframe: HTMLIFrameElement | null,
dragOffset: { x: number; y: number } | undefined,
callbacks: GsapDragCommitCallbacks,
): Promise<GsapEditOutcome> {
const selector = selectorFromSelection(selection);
const moves = !!dragOffset && (dragOffset.x !== 0 || dragOffset.y !== 0) && !!selector;
const anchor =
moves && animationWritesAnyProperty(anim, POSITION_XY)
? computeDraggedGsapPosition(
selection.element,
dragOffset,
readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 },
)
: null;
const written = await commitValueAtPlayhead(
selection,
anim,
anchor ? { ...size, x: anchor.newX, y: anchor.newY } : size,
iframe,
callbacks,
{
label: "Resize",
backfill: {
...preGestureBoxSize(selection.element),
...(anchor && { x: anchor.baseGsapX, y: anchor.baseGsapY }),
},
...(anchor && { beforeReload: () => restoreDragOffset(selection.element) }),
},
);
return written.status === "persisted" && anchor ? { ...written, ownsDragOffset: true } : written;
}
/**
* Whether this tween already states scale as `scaleX`/`scaleY`.
*
@@ -98,6 +134,7 @@ export async function tryGsapResizeIntercept(
iframe: HTMLIFrameElement | null,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
dragOffset?: { x: number; y: number },
): Promise<GsapEditOutcome> {
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
@@ -120,7 +157,7 @@ export async function tryGsapResizeIntercept(
postSplitFetch,
);
let anim =
const anim =
resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
? resolved.anim
: null;
@@ -166,15 +203,11 @@ export async function tryGsapResizeIntercept(
);
if (animatedTween) {
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
const handled = await commitKeyframedSizeFromResize(
selection,
size,
sel,
sizeSet,
animatedTween,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
);
if (handled) return { status: "persisted" };
const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
return commitSizeAtPlayhead(selection, animatedTween, sized, iframe, dragOffset, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
}
}
@@ -196,16 +229,10 @@ export async function tryGsapResizeIntercept(
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
if (activeKeyframePct != null) setActiveKeyframePct(null);
const selector = selectorFromSelection(selection);
// Scope every capture to the resize group — same contract as the rotation
// intercept. Unfiltered, an opacity-touching intro tween on the element
// would ride into resize conversions/backfills (the Fix-2 bake class).
const runtimeProps = selector
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
: {};
let resizeProps: Record<string, number>;
let resizeBackfill: Record<string, number>;
let scaleDraftEl: HTMLElement | null = null;
let scaleDraftDropPoint: { x: number; y: number } | null = null;
/** The scale this commit is putting on the element, for the finalize step. */
@@ -262,6 +289,7 @@ export async function tryGsapResizeIntercept(
resizeProps = useScaleLonghands
? { scaleX: newScaleX, scaleY: newScaleY }
: { scale: newScaleX };
resizeBackfill = { scaleX: liveScaleX, scaleY: liveScaleY };
logResize("intercept-route", {
route: "scale-tween",
cssW,
@@ -295,6 +323,7 @@ export async function tryGsapResizeIntercept(
width: roundToLayoutPx(size.width),
height: roundToLayoutPx(size.height),
};
resizeBackfill = preGestureBoxSize(selection.element);
}
// Finalize a scale-route commit: tear down the gesture's inline width/height
// draft (leaving it applied compounds with the committed scale — the element
@@ -404,10 +433,12 @@ export async function tryGsapResizeIntercept(
);
if (positionTween) {
logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, selector, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
assertGsapEditPersisted(
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
}),
);
return true;
}
const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
@@ -435,135 +466,14 @@ export async function tryGsapResizeIntercept(
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
}
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = tweenDuration;
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
// Outside-range uses the extend path which handles everything atomically.
if (!outsideRange) {
// fallow-ignore-next-line code-duplication
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
if (newId) anim = { ...anim, id: newId };
} else if (!anim.keyframes) {
const resolvedFromValues = selector
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
: undefined;
await commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
{ label: "Convert to keyframes for resize" },
);
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const refreshed = fresh.find(
(a) => a.targetSelector === anim!.targetSelector && a.keyframes,
);
if (refreshed) anim = refreshed;
}
}
}
// A NON-uniform scale must also take the full-rewrite path: it mixes
// scaleX/scaleY into a tween whose existing keyframes may carry the uniform
// `scale` shorthand, and GSAP's percentage keyframes animate each property
// name independently — a shorthand/longhand mix would leave the old `scale`
// sub-tween running against the new scaleX/scaleY. The rewrite below
// normalizes every keyframe to the longhands. For an in-range resize the
// min/max window math below degenerates to the tween's own start/duration,
// so timing is unchanged.
if ((outsideRange || useScaleLonghands) && ts !== null) {
// For flat tweens, synthesize the keyframes from the tween's properties
const kfs =
anim.keyframes?.keyframes ??
(() => {
const fromProps =
anim.method === "from" || anim.method === "fromTo"
? { ...anim.properties }
: synthesizeIdentityProps(anim.properties);
const toProps =
anim.method === "from"
? synthesizeIdentityProps(anim.properties)
: { ...anim.properties };
return [
{ percentage: 0, properties: fromProps },
{ percentage: 100, properties: toProps },
];
})();
const newStart = Math.min(ct, ts);
const newEnd = Math.max(ct, ts + td);
const newDuration = Math.max(0.01, newEnd - newStart);
const existingKfs = kfs;
const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
for (const kf of existingKfs) {
const absTime = ts + (kf.percentage / 100) * td;
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
const props = { ...kf.properties };
// Normalize the uniform `scale` shorthand to longhands when this commit
// writes scaleX/scaleY, so the tween never mixes the two forms.
if (nonUniformScale && "scale" in props) {
const uniform = props.scale;
if (typeof uniform === "number") {
props.scaleX = uniform;
props.scaleY = uniform;
}
delete props.scale;
}
// Only backfill properties that the animation already had (x, y, scale).
// Don't backfill width/height — they should only appear on the resize keyframe.
for (const k of Object.keys(resizeProps)) {
if (k in props) continue;
if (k === "width" || k === "height") continue;
props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
}
remapped.push({ percentage: newPct, properties: props });
}
const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
// An in-range rewrite can land on an existing keyframe's percentage —
// merge into it instead of emitting a duplicate step.
const collidingKf = remapped.find((kf) => Math.abs(kf.percentage - targetPct) < 0.05);
if (collidingKf) Object.assign(collidingKf.properties, resizeProps);
else remapped.push({ percentage: targetPct, properties: resizeProps });
remapped.sort((a, b) => a.percentage - b.percentage);
await commitMutation(
selection,
{
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: anim.targetSelector,
position: roundTo3(newStart),
duration: roundTo3(newDuration),
keyframes: remapped,
},
{
label: outsideRange
? `Resize (extended to ${ct.toFixed(2)}s)`
: `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
softReload: true,
...(!collidingKf ? { keyframeAction: "add" as const } : {}),
},
);
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
}
const backfillDefaults: Record<string, number> = {};
for (const k of Object.keys(runtimeProps)) {
if (SIZE_PROPS.has(k)) continue;
backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
}
await commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties: resizeProps,
backfillDefaults,
},
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
);
const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations };
if (resizeGroup === "size")
return commitSizeAtPlayhead(selection, anim, resizeProps, iframe, dragOffset, callbacks);
const written = await commitValueAtPlayhead(selection, anim, resizeProps, iframe, callbacks, {
label: "Resize",
backfill: resizeBackfill,
});
if (written.status !== "persisted") return written;
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
}
@@ -23,57 +23,40 @@ import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { elTween, resetGsapEditState } from "./gsapParsedTween.test-helpers";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
afterEach(() => {
vi.restoreAllMocks();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
document.body.innerHTML = "";
});
afterEach(resetGsapEditState);
type Props = Record<string, number>;
function tween(id: string, properties: Props, duration: number): GsapAnimation {
return {
id,
targetSelector: "#el",
propertyGroup: classifyTweenPropertyGroup(properties),
method: "to",
properties,
position: 0,
resolvedStart: 0,
duration,
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
} as unknown as GsapAnimation;
}
/** The dimensions an element's animations actually vary across. */
const SCALE = {
none: null,
"instant hold": () => tween("#el-scale", { scale: 1.2 }, 0),
tween: () => tween("#el-scale", { scale: 1.2 }, 2),
longhands: () => tween("#el-scale", { scaleX: 1.2, scaleY: 1.1 }, 2),
"instant hold": () => elTween("#el-scale", { scale: 1.2 }, 0),
tween: () => elTween("#el-scale", { scale: 1.2 }, 2),
longhands: () => elTween("#el-scale", { scaleX: 1.2, scaleY: 1.1 }, 2),
} as const;
const SIZE = {
none: null,
"instant hold": () => tween("#el-size", { width: 300, height: 200 }, 0),
tween: () => tween("#el-size", { width: 300, height: 200 }, 2),
"instant hold": () => elTween("#el-size", { width: 300, height: 200 }, 0),
tween: () => elTween("#el-size", { width: 300, height: 200 }, 2),
} as const;
const POSITION = {
none: null,
"static hold": () => tween("#el-position", { x: 40, y: 60 }, 0),
tween: () => tween("#el-position", { x: 40, y: 60 }, 2),
"static hold": () => elTween("#el-position", { x: 40, y: 60 }, 0),
tween: () => elTween("#el-position", { x: 40, y: 60 }, 2),
} as const;
const EXTRA = {
none: null,
// What a 3D card carries, and the shape that produced two same-group ids.
"3d and rotation": () => [
tween("#el-other", { rotationY: -540, rotationX: 720, _auto: 0 }, 0),
tween("#el-rotation", { rotation: 720 }, 0),
tween("#el-other-2", { z: 50 }, 0),
elTween("#el-other", { rotationY: -540, rotationX: 720, _auto: 0 }, 0),
elTween("#el-rotation", { rotation: 720 }, 0),
elTween("#el-other-2", { z: 50 }, 0),
],
// A tween that already spans two groups, which the resize has to split.
"a mixed tween": () => [tween("#el-mixed", { scale: 1.2, width: 300, height: 200 }, 0)],
"a mixed tween": () => [elTween("#el-mixed", { scale: 1.2, width: 300, height: 200 }, 0)],
} as const;
interface Recorded {
@@ -113,7 +96,7 @@ function fakeSource(initial: GsapAnimation[]) {
current = [
...current.filter((animation) => animation.id !== id),
...[...byGroup].map(([group, properties]) =>
tween(`#el-split-${group}`, properties, target.duration ?? 0),
elTween(`#el-split-${group}`, properties, target.duration ?? 0),
),
];
};
@@ -123,7 +106,7 @@ function fakeSource(initial: GsapAnimation[]) {
if (mutation.type === "split-into-property-groups") return id && split(id);
if (!id) {
if (mutation.type === "add")
current = [...current, tween(`#el-added-${current.length}`, properties, 0)];
current = [...current, elTween(`#el-added-${current.length}`, properties, 0)];
return;
}
mergeInto(id, properties);
@@ -224,7 +207,7 @@ it(`sweeps ${CASES.length} animated shapes without a stale id or a mixed tween`,
*/
function scaleAt(id: string, position: number): GsapAnimation {
return {
...tween(id, { scale: 1 }, 2),
...elTween(id, { scale: 1 }, 2),
position,
resolvedStart: position,
keyframes: {
@@ -121,7 +121,7 @@ describe("tryGsapDragIntercept: which tweens a word's drag may use", () => {
});
describe("tryGsapDragIntercept: one drag is one undo step", () => {
it("records the split and the move under one gesture key", async () => {
it("records the move under one gesture key, without splitting the mixed tween", async () => {
document.body.innerHTML = `<div id="hero"></div>`;
const hero = document.getElementById("hero")!;
const mixed = {
@@ -133,12 +133,8 @@ describe("tryGsapDragIntercept: one drag is one undo step", () => {
position: 0,
resolvedStart: 0,
duration: 1,
ease: "none",
} as unknown as GsapAnimation;
const split = [
{ ...mixed, id: "#hero-fromTo-0-position", propertyGroup: "position" },
{ ...mixed, id: "#hero-fromTo-0-opacity", propertyGroup: "opacity" },
] as GsapAnimation[];
let fetches = 0;
const commitMutation = vi.fn();
await tryGsapDragIntercept(
{ id: "hero", selector: "#hero", element: hero } as unknown as DomEditSelection,
@@ -146,10 +142,12 @@ describe("tryGsapDragIntercept: one drag is one undo step", () => {
[mixed],
null,
commitMutation,
async () => (fetches++ === 0 ? [mixed] : split),
async () => [mixed],
);
const types = commitMutation.mock.calls.map((call) => call[1].type);
const options = commitMutation.mock.calls.map((call) => call[2]);
expect(options.length).toBeGreaterThan(1);
expect(types).not.toContain("split-into-property-groups");
expect(options.length).toBeGreaterThan(0);
expect(new Set(options.map((o) => o.coalesceKey)).size).toBe(1);
expect(options[0].coalesceKey).toMatch(/^gsap:drag:/);
expect(options.every((o) => o.coalesceMs === Number.POSITIVE_INFINITY)).toBe(true);
@@ -23,7 +23,7 @@ function fakeIframe(elId: string, children: unknown[]): HTMLIFrameElement {
return {
contentWindow: {
__timelines: { "index.html": timeline },
gsap: { getProperty: () => 0 },
gsap: { getProperty: () => 0, defaults: () => ({ ease: "power1.out" }) },
},
contentDocument: { querySelector: (sel: string) => (sel === `#${elId}` ? el : null) },
} as unknown as HTMLIFrameElement;
@@ -451,7 +451,8 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
fakeIframe("puck-b", []),
commitMutation,
);
return { handled, types: commitMutation.mock.calls.map(([, mutation]) => mutation.type) };
const mutations = commitMutation.mock.calls.map(([, mutation]) => mutation);
return { handled, mutations, types: mutations.map((mutation) => mutation.type) };
}
it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
@@ -461,10 +462,40 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
expect(types).not.toContain("add-keyframe");
});
it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
const { handled, types } = await runAutoKeyframeDrag(true);
it("shifts a linear flat tween as keyframes that stay linear", async () => {
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 1 });
const commitMutation = vi.fn();
const linear = { ...stalePositionAnim, ease: "none" } as GsapAnimation;
const live = {
targets: () => [{ id: "puck-b" }],
vars: { x: -180, y: -60, ease: "none" },
duration: () => 2,
startTime: () => 1,
};
await tryGsapDragIntercept(
selection,
{ x: -50, y: 0 },
[linear],
fakeIframe("puck-b", [live]),
commitMutation,
);
const [mutation] = commitMutation.mock.calls.map(([, m]) => m);
expect(mutation).toMatchObject({ type: "replace-with-keyframes", easeEach: "none" });
expect(mutation).not.toHaveProperty("ease", "none");
});
it("still changes only the keyframe at the playhead when the toggle is on (default)", async () => {
const { handled, mutations } = await runAutoKeyframeDrag(true);
expect(handled).toEqual({ status: "persisted" });
expect(types).not.toContain("replace-with-keyframes");
expect(mutations).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: -50, y: 0 } },
],
}),
]);
});
});
@@ -540,7 +571,11 @@ describe("tryGsapDragIntercept — motion paths", () => {
],
ease: "none",
},
expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }),
expect.objectContaining({
label: "Move layer (new keyframe)",
keyframeAction: "add",
softReload: true,
}),
);
expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
"add-motion-path-point",
+105 -131
View File
@@ -9,12 +9,13 @@
* easing, or seek position.
*/
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import { isXYPositionWrite } from "@hyperframes/parsers/gsap-constants";
import { isXYPositionWrite, PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
import { commitGsapPositionFromDrag, gsapPositionFromDragOutcome } from "./gsapDragPositionCommit";
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
import {
commitStaticGsapPosition,
commitStaticGsapRotation,
@@ -22,7 +23,6 @@ import {
computeCurrentPercentage,
findExistingPositionWrite,
findRotationSetAnimation,
materializeIfDynamic,
} from "./gsapDragCommit";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { isGestureTransactionCommit } from "./gestureTransaction";
@@ -54,64 +54,29 @@ const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
// ── Property-group tween resolution ───────────────────────────────────────
/**
* Find the tween for a given property group, splitting a legacy mixed tween
* if necessary. Returns the resolved animation or null if none exists.
*
* Resolution order:
* 1. Tween already tagged with `propertyGroup === group`
* 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation,
* re-fetch, then return the group tween
* 3. null — caller must handle the missing-tween case
* The tween to edit for a property group: a tween tagged with it, else a legacy tween that mixes
* it with other groups. A mixed tween is edited in place, never split first: inside a gesture the
* split is only buffered, so ids read after it are stale and the edit lands beside the old tween.
*/
export async function resolveGroupTween(
group: PropertyGroupName,
animations: GsapAnimation[],
selection: DomEditSelection,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
_selection: DomEditSelection,
_commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
// 1. Already-split group tween — pick the one closest to the current
// playhead so a drag at t=6s edits the tween at 4s, not the one at 1.5s.
const groupAnims = animations.filter((a) => a.propertyGroup === group);
const groupAnim = pickClosestToPlayhead(groupAnims);
if (groupAnim) return { anim: groupAnim, animations };
// 2. Legacy mixed tween — split it, then re-fetch
const legacyMixed = animations.find((a) => !a.propertyGroup);
if (legacyMixed) {
await commitMutation(
selection,
{ type: "split-into-property-groups", animationId: legacyMixed.id },
{ label: "Split mixed tween into property groups", skipReload: true },
);
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
}
}
// 3. Try fallback fetch (no split needed, just wasn't in the initial list)
if (!legacyMixed && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
// Fallback: legacy mixed in the fresh list
const freshLegacy = fresh.find((a) => !a.propertyGroup);
if (freshLegacy) {
await commitMutation(
selection,
{ type: "split-into-property-groups", animationId: freshLegacy.id },
{ label: "Split mixed tween into property groups", skipReload: true },
);
const reFetched = await fetchFallbackAnimations();
const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group);
if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched };
}
}
return null;
const inGroup = (list: GsapAnimation[]) => {
const tagged = list.filter((a) => a.propertyGroup === group);
const props = new Set(PROPERTY_GROUPS[group]);
const mixed = list.filter((a) => !a.propertyGroup && animationWritesAnyProperty(a, props));
return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed);
};
const anim = inGroup(animations);
if (anim) return { anim, animations };
if (!fetchFallbackAnimations) return null;
const fresh = await fetchFallbackAnimations();
const freshAnim = inGroup(fresh);
return freshAnim ? { anim: freshAnim, animations: fresh } : null;
}
// ── High-level intercept ───────────────────────────────────────────────────
@@ -205,6 +170,59 @@ function oneUndoStep(
commit(selection, mutation, { ...options, coalesceKey, coalesceMs: Number.POSITIVE_INFINITY });
}
const isPositionWriteOf = (selector: string) => (a: GsapAnimation) =>
a.targetSelector === selector && isXYPositionWrite(a);
/** The one position write a self-heal keeps when holds fight over `selector`, else null. */
function positionWriteKeeper(animations: GsapAnimation[], selector: string): GsapAnimation | null {
const dupes = animations.filter(isPositionWriteOf(selector));
// Real tweens one after another are a motion, not a conflict: only holds can fight.
if (dupes.length < 2 || dupes.filter((a) => !isInstantHold(a)).length > 1) return null;
return dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
}
/** Where a drag writes. With no live motion and no keyframed tween (a hold, or a
* zero-length keyframed tween) the position belongs in a `tl.set`, never keyframes. */
function dragRoute(
posAnim: GsapAnimation | null,
iframe: HTMLIFrameElement | null,
selector: string,
altKey?: boolean,
): "static" | "whole-path" | "at-playhead" {
const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
if (!hasNonHold && !hasKeyframedPosTween) return "static";
// Alt-drag shifts the whole path; with auto-keyframe off (#1808) that is the default.
return altKey || !usePlayerStore.getState().autoKeyframeEnabled ? "whole-path" : "at-playhead";
}
/** The commit's route and keyframe plan, writing nothing, so a group refuses whole. */
async function planDrag(
selection: DomEditSelection,
offset: { x: number; y: number },
allAnimations: GsapAnimation[],
iframe: HTMLIFrameElement | null,
options: { altKey?: boolean },
): Promise<GsapEditOutcome> {
const selector = selectorFromSelection(selection);
if (!selector) return { status: "blocked", reason: "no-selector" };
const own = tweensForThisElement(selection, allAnimations);
const keeper = positionWriteKeeper(own, selector);
const animations = keeper
? own.filter((a) => a === keeper || !isPositionWriteOf(selector)(a))
: own;
const resolved = await resolveGroupTween("position", animations, selection, async () => {});
const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector);
const route = dragRoute(posAnim, iframe, selector, options.altKey);
if (route === "static") return { status: "persisted" };
if (!posAnim) {
return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
}
if (route === "whole-path") return { status: "persisted" };
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
}
/** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
* restores its drafts) and save `element-offset` on the element itself. */
export async function tryGsapDragIntercept(
@@ -229,7 +247,12 @@ export async function tryGsapDragIntercept(
fetchAllAnimations,
options?.group,
);
if (preflight.status !== "persisted" || options?.preflightOnly) return preflight;
if (preflight.status !== "persisted") return preflight;
if (options?.preflightOnly) {
return options.group
? planDrag(selection, offset, allAnimations, iframe, options)
: preflight;
}
}
const animations = tweensForThisElement(selection, allAnimations);
const fetchFallbackAnimations =
@@ -246,13 +269,10 @@ export async function tryGsapDragIntercept(
// the live keyframed/real tween if present (else any), strip the rest, so the
// commit below updates ONE write instead of fighting duplicates.
let workingAnimations = animations;
const isPosWrite = (a: GsapAnimation) => a.targetSelector === selector && isXYPositionWrite(a);
if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
if (animations.filter(isPositionWriteOf(selector)).length > 1 && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const dupes = fresh.filter(isPosWrite);
if (dupes.length > 1) {
const keeper =
dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
const keeper = positionWriteKeeper(fresh, selector);
if (keeper) {
await commitMutation(
selection,
{
@@ -288,26 +308,8 @@ export async function tryGsapDragIntercept(
}
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
// STATIC case (single source of truth = GSAP timeline): the element has no LIVE
// keyframed/tweened position motion. Use the strict non-hold check — a leftover
// position-hold `set` (after a delete-all, or a stale parse that lags it) must
// NOT count as live motion. Either way the position belongs in a
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
// place (idempotent), else add a new one. This also covers the stale-cache
// phantom — committing a set is correct because the element genuinely has no live motion.
const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
// A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
// e.g. 0% and 100% identical) — is still an animation the user is building, so a
// drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
// dragging an element whose position tween is constant writes a `gsap.set` that
// fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
// static path is only for elements with NO keyframed position tween (truly static,
// or just a leftover position-hold `set`).
// A zero-duration keyframed tween is a static HOLD, not a live animation —
// treat it as static so the drag heals it instead of feeding it more keyframes.
const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
if (!hasNonHold && !hasKeyframedPosTween) {
const route = dragRoute(posAnim, iframe, selector, options?.altKey);
if (route === "static") {
const existingSet =
posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
? posAnim
@@ -329,27 +331,26 @@ export async function tryGsapDragIntercept(
// current ID and avoid a stale-ID remove that creates duplicate tweens.
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const freshMatch = fresh.find(
(a) =>
a.targetSelector === posAnim!.targetSelector &&
a.propertyGroup === posAnim!.propertyGroup &&
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
);
const freshMatch =
fresh.find((a) => a.id === posAnim!.id) ??
pickClosestToPlayhead(
fresh.filter(
(a) =>
a.targetSelector === posAnim!.targetSelector &&
a.propertyGroup === posAnim!.propertyGroup &&
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
),
);
if (freshMatch && freshMatch.id !== posAnim.id) {
posAnim = freshMatch;
}
}
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
// Alt-drag already means "shift the whole path" — the global auto-keyframe
// toggle (#1808) just makes that the default while it's off, so a manual
// edit on an already-animated element nudges the animation instead of
// inserting/updating a keyframe at the playhead.
const autoKeyframeEnabled = usePlayerStore.getState().autoKeyframeEnabled;
if (options?.altKey || !autoKeyframeEnabled) {
if (route === "whole-path") {
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
} else {
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
return commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, cbs);
}
return { status: "persisted" };
}
@@ -428,8 +429,9 @@ export async function tryGsapRotationIntercept(
? resolved.anim
: null;
if (!anim) {
anim =
workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
anim = pickClosestToPlayhead(
workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)),
);
}
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
@@ -469,42 +471,14 @@ export async function tryGsapRotationIntercept(
return { status: "persisted" };
}
// fallow-ignore-next-line code-duplication
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
if (newId) anim = { ...anim, id: newId };
} else if (!anim.keyframes) {
const resolvedFromValues = selector
? readAllAnimatedProperties(iframe, selector, anim, "rotation")
: undefined;
await commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
{ label: "Convert to keyframes for rotation", skipReload: true },
);
}
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation");
const backfillDefaults: Record<string, number> = { ...runtimeProps };
if (!("rotation" in runtimeProps)) {
backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0;
}
const properties = { ...runtimeProps, rotation: newRotation };
await commitMutation(
return commitValueAtPlayhead(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
backfillDefaults,
},
{ label: `Rotate (keyframe ${pct}%)`, softReload: true },
anim,
{ rotation: newRotation },
iframe,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
{ label: "Rotate", backfill: { rotation: newRotation }, holdFromStart: true },
);
return { status: "persisted" };
}
export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
@@ -4,6 +4,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
export { PROPERTY_DEFAULTS } from "./gsapShared";
import { idSelector, matchesExactlyOne } from "./gsapShared";
import { studioWriteHeaders } from "../utils/studioFileVersion";
import { studioApiFetch } from "../utils/studioApiFetch";
/**
* The selector to author a NEW tween against, minting an id on the element when
@@ -116,7 +117,7 @@ export async function assignGsapTargetAutoIdIfNeeded({
showToast?.("Couldn't assign element id because the patch contains invalid values", "error");
return false;
}
const res = await fetch(
const res = await studioApiFetch(
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
@@ -5,6 +5,14 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
import type { KeyframeUsageAction } from "../utils/keyframeUsage";
/** The file a selection's GSAP write goes to. */
export function gsapWriteFile(
selection: Pick<DomEditSelection, "sourceFile">,
activeCompPath: string | null | undefined,
): string {
return selection.sourceFile || activeCompPath || "index.html";
}
export interface MutationResult {
ok: boolean;
changed?: boolean;
+7
View File
@@ -34,6 +34,13 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
height: 100,
};
/** A tween's eases for re-writing it as keyframes: a flat tween's ease must become easeEach,
* since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } {
if (!anim.keyframes) return { easeEach: anim.ease };
return { ease: anim.ease, easeEach: anim.keyframes.easeEach };
}
/**
* A timeline write that applies an instantaneous value and then holds it.
* `set()` is always a hold; authored `to()` / `fromTo()` tweens are holds only
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { parseGsapScript } from "@hyperframes/core/gsap-parser";
import { addAnimationWithKeyframesToScript } from "@hyperframes/parsers/gsap-writer-acorn";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
import { mountGroupSiblings, stableSelectionFor } from "./domSelectionTestHarness";
import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -16,38 +16,6 @@ afterEach(() => {
document.body.innerHTML = "";
});
/**
* A selection built the way production builds it (getDomLayerPatchTarget), so
* the class-only case under test is the real one: buildStableSelector hands back
* a BARE class for an element with no id / hf-id / composition id.
*/
function selectionFor(el: HTMLElement): DomEditSelection {
const selector = buildStableSelector(el);
return {
element: el,
id: el.id || undefined,
hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
sourceFile: "index.html",
dataAttributes: { start: "0", duration: "2" },
} as unknown as DomEditSelection;
}
/** Five class-only siblings — the shape that made one add collapse the timeline. */
function mountGroupSiblings(): HTMLElement[] {
document.body.innerHTML = `
<div id="scene" class="clip" data-start="0" data-duration="2">
<div class="group"></div>
<div class="group"></div>
<div class="group"></div>
<div class="group"></div>
<div class="group"></div>
</div>
`;
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
}
const BASE_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#scene", { opacity: 1, duration: 0.5 }, 0);
@@ -68,8 +36,8 @@ function roundTripTargets(selector: string): { script: string; targets: string[]
describe("writeTargetSelector", () => {
it("addresses exactly one element when the only identity is a shared class", () => {
const groups = mountGroupSiblings();
const selection = selectionFor(groups[2]);
const groups = mountGroupSiblings(5);
const selection = stableSelectionFor(groups[2]);
// Precondition: this is the defect's input — a bare class hitting all five.
expect(selection.selector).toBe(".group");
@@ -86,7 +54,7 @@ describe("writeTargetSelector", () => {
document.body.innerHTML = `<div id="box" class="card"></div>`;
const el = document.querySelector<HTMLElement>("#box")!;
expect(writeTargetSelector(selectionFor(el))).toBe("#box");
expect(writeTargetSelector(stableSelectionFor(el))).toBe("#box");
});
it("prefers data-hf-id over a generated structural selector", () => {
@@ -95,7 +63,7 @@ describe("writeTargetSelector", () => {
`;
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
const written = writeTargetSelector(selectionFor(el));
const written = writeTargetSelector(stableSelectionFor(el));
expect(written).toBe('[data-hf-id="hf-42"]');
expect(document.querySelector(written!)).toBe(el);
@@ -105,7 +73,7 @@ describe("writeTargetSelector", () => {
document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
const el = document.querySelector<HTMLElement>(".header")!;
expect(writeTargetSelector(selectionFor(el))).toBe(".header");
expect(writeTargetSelector(stableSelectionFor(el))).toBe(".header");
});
it("hands identity back to a data-hf-id ancestor part way up a deep chain", () => {
@@ -125,7 +93,7 @@ describe("writeTargetSelector", () => {
`;
const el = document.querySelectorAll<HTMLElement>(".cell")[2]!;
const written = writeTargetSelector(selectionFor(el));
const written = writeTargetSelector(stableSelectionFor(el));
expect(written).toBe('[data-hf-id="mid"] > div:nth-child(3)');
expect(document.querySelectorAll(written!)).toHaveLength(1);
@@ -137,7 +105,7 @@ describe("writeTargetSelector", () => {
const el = document.querySelectorAll<HTMLElement>("#scene > div")[1]!;
// querySelectorAll throws on this, so the "does it match exactly one?" rung
// must read as a miss rather than crashing the add.
const selection = { ...selectionFor(el), selector: "div[unclosed" } as DomEditSelection;
const selection = { ...stableSelectionFor(el), selector: "div[unclosed" } as DomEditSelection;
expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
});
@@ -145,7 +113,7 @@ describe("writeTargetSelector", () => {
it("returns null when a live DOM is present and no rung addresses one element", () => {
document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
const selection = selectionFor(el);
const selection = stableSelectionFor(el);
expect(selection.selector).toBe(".group");
// Detached between selecting and committing: ownerDocument still resolves,
// but there is no parent to count a :nth-child position in. Returning
@@ -167,11 +135,11 @@ describe("writeTargetSelector", () => {
describe("writeTargetSelector — write/read round trip", () => {
it("re-parses and attributes the new tween to the one element it targeted", () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5);
// Ids let resolveSelectorElementIds name the attributed elements; the
// SELECTION still has none, so the write path still faces the bare class.
groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
const selection = { ...selectionFor(groups[2]), id: undefined } as DomEditSelection;
const selection = { ...stableSelectionFor(groups[2]), id: undefined } as DomEditSelection;
const written = writeTargetSelector(selection);
const { targets } = roundTripTargets(written!);
@@ -180,9 +148,9 @@ describe("writeTargetSelector — write/read round trip", () => {
});
it("does not widen the add to the element's four class siblings", () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5);
groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
const selection = { ...selectionFor(groups[0]), id: undefined } as DomEditSelection;
const selection = { ...stableSelectionFor(groups[0]), id: undefined } as DomEditSelection;
// The old bare-class write attributed the one add to every sibling — the
// attribution blow-up behind "one add collapsed the timeline to a single row".
@@ -194,8 +162,8 @@ describe("writeTargetSelector — write/read round trip", () => {
describe("existingTweenTargetSelector", () => {
it("keeps the narrowed target when a replace re-authors the tween", async () => {
const groups = mountGroupSiblings();
const selection = selectionFor(groups[2]);
const groups = mountGroupSiblings(5);
const selection = stableSelectionFor(groups[2]);
const commitMutation = vi.fn(async () => undefined);
// A tween a previous add already narrowed to one sibling. Re-deriving the
// target from the selection would widen it back to all five.
@@ -215,8 +183,8 @@ describe("existingTweenTargetSelector", () => {
});
it("falls back to the selection when the tween's own target did not resolve", async () => {
const groups = mountGroupSiblings();
const selection = selectionFor(groups[2]);
const groups = mountGroupSiblings(5);
const selection = stableSelectionFor(groups[2]);
const commitMutation = vi.fn(async () => undefined);
const setAnim = {
id: "a1",
@@ -236,8 +204,8 @@ describe("existingTweenTargetSelector", () => {
describe("commitKeyframeAtTimeImpl — new-tween target", () => {
it("authors a one-element selector when no tween exists at the playhead", async () => {
const groups = mountGroupSiblings();
const selection = selectionFor(groups[3]);
const groups = mountGroupSiblings(5);
const selection = stableSelectionFor(groups[3]);
const commitMutation = vi.fn(async () => undefined);
await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
@@ -251,7 +219,7 @@ describe("commitKeyframeAtTimeImpl — new-tween target", () => {
describe("tweenTargetsElement", () => {
it("matches a tween narrowed to this element by a selector the selection does not use", () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5);
groups[2]!.id = "narrowed";
// The write half authored "#narrowed"; the read half still has ".group".
@@ -259,7 +227,7 @@ describe("tweenTargetsElement", () => {
});
it("does not hand an individual element the group tween it merely inherits", () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5);
// Selecting one sibling by its own address must not let an edit mutate the
// ".group" tween: that write moves all five, not the one being nudged.
@@ -267,13 +235,13 @@ describe("tweenTargetsElement", () => {
});
it("still edits a group tween when the group itself is the selection", () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5);
expect(tweenTargetsElement(".group", ".group", groups[0])).toBe(true);
});
it("does not match a target that is not a selector matches() understands", () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5);
expect(tweenTargetsElement("div[unclosed", ".group", groups[0])).toBe(false);
});
@@ -281,8 +249,8 @@ describe("tweenTargetsElement", () => {
describe("commitKeyframeAtTimeImpl — no one-element target", () => {
it("drops the keyframe rather than authoring the bare class", async () => {
const groups = mountGroupSiblings();
const selection = selectionFor(groups[3]!);
const groups = mountGroupSiblings(5);
const selection = stableSelectionFor(groups[3]!);
groups[3]!.remove();
const commitMutation = vi.fn(async () => undefined);
@@ -297,7 +265,7 @@ it("counts a keyframe-at-time conversion and insertion as one add gesture", asyn
const el = document.createElement("div");
el.id = "target";
document.body.append(el);
const selection = selectionFor(el);
const selection = stableSelectionFor(el);
const animations = parseGsapScript(
'const tl = gsap.timeline(); tl.to("#target", {x:100,duration:4},0);',
).animations;
@@ -0,0 +1,83 @@
// @vitest-environment happy-dom
// Real Chrome with the built runtime: its seek, not a plain GSAP seek, decides what a saved edit shows.
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { gsap } from "gsap";
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
import puppeteer from "puppeteer-core";
import { expect, it, vi } from "vitest";
import { findSystemChrome } from "../../vite.browser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { planValueEdit } from "./gsapValueAtPlayhead";
const require = createRequire(import.meta.url);
vi.setConfig({ testTimeout: 60_000 });
const SRC = `var tl = gsap.timeline({ paused: true });
tl.from("#target", { x: -60, duration: 2, ease: "none" }, 0);
tl.fromTo("#target", { x: 0 }, { x: 60, duration: 1, ease: "none" }, 2);
window.__timelines["main"] = tl;`;
/** Plans and writes a drag of `#target` to `x` at `at`, as the move gesture saves it. */
function writeDrag(x: number, at: number): string {
const box = Object.assign(document.body.appendChild(document.createElement("div")), {
id: "target",
});
const win = { __timelines: {} as Record<string, gsap.core.Timeline> };
new Function("gsap", "window", SRC)(gsap, win);
const timeline = win.__timelines.main!;
timeline.progress(0.0001, true).seek(at);
usePlayerStore.setState({ currentTime: at, activeKeyframePct: null });
const iframe = { contentWindow: { ...win, gsap } } as unknown as HTMLIFrameElement;
const anim = parseGsapScriptAcorn(SRC).animations[1]!;
const selection = { id: "target", selector: "#target", element: box } as DomEditSelection;
const plan = planValueEdit(selection, anim, { x }, iframe);
timeline.kill();
if (!plan.ok) throw new Error(`the drag was refused: ${JSON.stringify(plan)}`);
return replaceTweenWithKeyframesInScript(SRC, anim.id, plan.mutation)!;
}
const page = (script: string) => `<!doctype html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="4" data-width="1920" data-height="1080">
<div id="target" class="clip" data-start="0" data-duration="4" data-track-index="1"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>window.__timelines = window.__timelines || {};\n${script}</script>
</body></html>`;
it("a drag on a fromTo's start shows the dropped x there once the runtime seeks", async () => {
const written = writeDrag(90, 2);
const executablePath = findSystemChrome();
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
const browser = await puppeteer.launch({
executablePath,
headless: true,
args: ["--no-sandbox"],
});
try {
const tab = await browser.newPage();
await tab.setRequestInterception(true);
// Only the GSAP script is served; anything else is refused, never fetched.
tab.on("request", (request) =>
request.url().endsWith("/gsap.min.js")
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
: request.abort("blockedbyclient"),
);
await tab.setContent(page(written), { waitUntil: "load" });
await tab.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
await tab.waitForFunction(() => "__player" in window);
const shown = await tab.evaluate(() => {
const w = window as unknown as {
__player: { seek(t: number): void };
gsap: { getProperty(target: string, prop: string): number };
};
w.__player.seek(2);
return w.gsap.getProperty("#target", "x");
});
expect(shown).toBeCloseTo(90, 1);
} finally {
await browser.close();
}
});
@@ -0,0 +1,179 @@
// @vitest-environment happy-dom
import { gsap } from "gsap";
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
import { afterEach, expect, it } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { findParsedTween, parsedImplicitEndValue, parsedTweenEase } from "./gsapParsedTween";
import { planValueEdit } from "./gsapValueAtPlayhead";
/** Runs a composition script as the preview does: a paused timeline, bound, then seeked to `at`. */
function play(script: string, at: number) {
const win = { __timelines: {} as Record<string, gsap.core.Timeline> };
new Function("gsap", "window", script)(gsap, win);
const timeline = win.__timelines.t!;
timeline.progress(0.0001, true).seek(at);
return { timeline, iframe: { contentWindow: { ...win, gsap } } as unknown as HTMLIFrameElement };
}
/** Drags `#x` to `x` at `at`, writes the plan into the script, and replays the written file there. */
function dragAndReplay(script: string, x: number, at: number) {
const box = document.body.appendChild(document.createElement("div"));
box.id = "x";
const { timeline, iframe } = play(script, at);
usePlayerStore.setState({ currentTime: at, activeKeyframePct: null });
const anim = parseGsapScriptAcorn(script).animations[0]!;
const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
const plan = planValueEdit(selection, anim, { x }, iframe);
timeline.kill();
if (!plan.ok) return { plan };
const written = replaceTweenWithKeyframesInScript(script, anim.id, plan.mutation)!;
const replay = play(written, at);
const shown = gsap.getProperty(box, "x");
replay.timeline.kill();
return { plan, written, shown };
}
const script = (vars: string) =>
`var tl = gsap.timeline({ paused: true });\ntl.to("#x", { ${vars} }, 0);\nwindow.__timelines["t"] = tl;`;
afterEach(() => {
document.body.replaceChildren();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
});
it("edits a delayed tween with no authored ease, landing the value at the playhead", () => {
const { plan, shown } = dragAndReplay(script("duration: 1, delay: 0.5, x: 100"), 200, 2);
expect(plan.ok).toBe(true);
expect(shown).toBe(200);
});
it("writes a delayed linear tween so GSAP shows the new value at the playhead, not later", () => {
const { shown } = dragAndReplay(script("duration: 1, delay: 0.5, x: 100, ease: 'none'"), 200, 2);
expect(shown).toBe(200);
});
it("keeps GSAP's default ease, by name, for a tween that authors none", () => {
const { plan } = dragAndReplay(script("duration: 1, x: 100"), 60, 0.5);
expect(plan.ok && plan.mutation.easeEach).toBe("power1.out");
});
it("reads a tween the playhead has not reached without redrawing a sibling's live value", () => {
const [x, y] = ["x", "y"].map((id) =>
Object.assign(document.body.appendChild(document.createElement("div")), { id }),
);
const src = `var tl = gsap.timeline({ paused: true });
tl.to("#y", { x: 100, duration: 1, ease: "none" }, 0);
tl.to("#x", { x: 300, duration: 1 }, 2);
window.__timelines["t"] = tl;`;
const { timeline, iframe } = play(src, 0.5);
gsap.set(y!, { x: 77 });
const tween = findParsedTween(iframe, x!, parseGsapScriptAcorn(src).animations[1]!);
expect(parsedImplicitEndValue(tween)("x", "start")).toBe(0);
expect(gsap.getProperty(y!, "x")).toBe(77);
expect(timeline.time()).toBe(0.5);
timeline.kill();
});
it("keeps a sibling's live attribute while reading an unplayed tween beside the runtime's filler", () => {
const [x, y] = ["x", "y"].map((id) =>
Object.assign(document.body.appendChild(document.createElement("div")), { id }),
);
y!.setAttribute("data-value", "0");
const src = `var tl = gsap.timeline({ paused: true });
tl.to({}, { duration: 4, data: "hf-runtime-filler" }, 0);
tl.to("#y", { attr: { "data-value": 100 }, duration: 1, ease: "none" }, 0);
tl.to("#x", { x: 300, duration: 1 }, 2);
window.__timelines["t"] = tl;`;
const { timeline, iframe } = play(src, 0.5);
y!.setAttribute("data-value", "77");
const tween = findParsedTween(iframe, x!, parseGsapScriptAcorn(src).animations.at(-1)!);
expect(parsedImplicitEndValue(tween)("x", "start")).toBe(0);
expect(y!.getAttribute("data-value")).toBe("77");
timeline.kill();
});
it("reads a later tween's start from the earlier tween on the same layer, as playback does", () => {
const x = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
const src = `var tl = gsap.timeline({ paused: true });
tl.to("#x", { x: 50, duration: 4, ease: "none" }, 0);
tl.to("#x", { x: 100, duration: 1, ease: "none", overwrite: "auto" }, 2);
window.__timelines["t"] = tl;`;
const { timeline, iframe } = play(src, 0.5);
const tween = findParsedTween(iframe, x, parseGsapScriptAcorn(src).animations[1]!);
expect(parsedImplicitEndValue(tween)("x", "start")).toBe(25);
timeline.seek(2.5);
expect(gsap.getProperty(x, "x")).toBe(62.5);
timeline.kill();
});
it("saves a dragged keyframe tween with its authored start, not the drag's live value", () => {
const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
const src = script(
"keyframes: { '0%': { opacity: 0 }, '100%': { opacity: 1, x: 300 } }, duration: 1, ease: 'none'",
).replace("}, 0);", "}, 2);");
const { timeline, iframe } = play(src, 0.5);
usePlayerStore.setState({ currentTime: 0.5, activeKeyframePct: null });
gsap.set(box, { x: 77 });
const anim = parseGsapScriptAcorn(src).animations[0]!;
const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
const plan = planValueEdit(selection, anim, { x: 77 }, iframe);
timeline.kill();
expect(plan.ok).toBe(true);
if (!plan.ok) return;
const written = replaceTweenWithKeyframesInScript(src, anim.id, plan.mutation)!;
const at = (time: number) => {
const replay = play(written, time);
const x = gsap.getProperty(box, "x");
replay.timeline.kill();
return x;
};
expect(at(0.5)).toBe(77);
expect(at(2)).toBe(0);
expect(at(3)).toBe(300);
});
it("keeps a layer's other transform values while reading an unplayed tween", () => {
const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
const src = script(
"keyframes: { '0%': { opacity: 0 }, '100%': { opacity: 1, x: 300 } }, duration: 1",
).replace("}, 0);", "}, 2);");
const { timeline, iframe } = play(src, 0.5);
gsap.set(box, {
x: 77,
skewX: 23,
rotationX: 19,
xPercent: 12,
transformOrigin: "20px 30px 40px",
});
const tween = findParsedTween(iframe, box, parseGsapScriptAcorn(src).animations[0]!);
timeline.kill();
expect(parsedImplicitEndValue(tween)("x", "start")).toBe(0);
const values = ["x", "skewX", "rotationX", "xPercent", "transformOrigin"];
expect(values.map((p) => gsap.getProperty(box, p))).toEqual([77, 23, 19, 12, "20px 30px 40px"]);
});
it("names only an ease GSAP built in, and refuses a custom function rather than guess", () => {
const custom = { vars: { ease: (p: number) => p * p } };
const iframe = { contentWindow: { gsap } } as unknown as HTMLIFrameElement;
expect(parsedTweenEase(iframe, { vars: { ease: gsap.parseEase("expo.in") } })).toBe("expo.in");
expect(parsedTweenEase(iframe, custom)).toBeNull();
});
it("writes an edit into a looping tween and keeps its repeat and yoyo, as main did", () => {
const vars = "duration: 2, x: 100, ease: 'none', repeat: 1, yoyo: true";
const { plan, written } = dragAndReplay(script(vars), 40, 1);
expect(plan.ok).toBe(true);
expect(written).toMatch(/repeat: 1[\s\S]*yoyo: true/);
});
@@ -0,0 +1,552 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, 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";
import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
import { planValueAtPlayhead, planValueEdit, type PlayheadEdit } from "./gsapValueAtPlayhead";
let el: HTMLElement;
let selection: DomEditSelection;
beforeEach(() => {
el = document.createElement("div");
el.id = "box";
document.body.append(el);
selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
});
afterEach(() => {
el.remove();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
});
/** Drags `#box` by `dx` from a pre-gesture GSAP position of `base`, at `time`. */
async function drag(
anims: GsapAnimation[],
live: unknown[],
{
time,
base,
dx,
values = {},
}: { time: number; base: [number, number]; dx: number; values?: Record<string, number> },
) {
usePlayerStore.setState({ currentTime: time });
el.setAttribute("data-hf-drag-gsap-base-x", String(base[0]));
el.setAttribute("data-hf-drag-gsap-base-y", String(base[1]));
const commitMutation = vi.fn();
const outcome = await tryGsapDragIntercept(
selection,
{ x: dx, y: 0 },
anims,
previewWith(el, live, values),
commitMutation,
async () => anims,
);
return { outcome, mutations: commitMutation.mock.calls.map((call) => call[1]) };
}
const fromX = tween({
id: "#box-from-0-position",
method: "from",
properties: { x: -60 },
resolvedStart: 0,
duration: 2,
ease: "none",
});
const fromXLive = (end: number) =>
liveTween(
el,
{ start: 0, duration: 2, vars: { x: -60, ease: "none" }, ends: { x: [-60, end] } },
{ from: true },
);
/** Two linear `to` tweens of `group` on `#box`, 0-2s then 2-3s, their live copies and a commit recorder. */
function backToBack(
group: "size" | "scale" | "rotation",
firstEnd: Record<string, number>,
secondEnd: Record<string, number>,
) {
const flat = (start: number, duration: number, properties: Record<string, number>) =>
tween({
id: `#box-to-${start * 1000}-${group}`,
propertyGroup: group,
method: "to",
properties,
resolvedStart: start,
duration,
ease: "none",
});
const tweens = [flat(0, 2, firstEnd), flat(2, 1, secondEnd)];
const live = tweens.map((a) =>
liveTween(el, { start: a.resolvedStart!, duration: a.duration!, vars: a.properties }),
);
const commitMutation = vi.fn();
const written = () => commitMutation.mock.calls.map((call) => call[1]);
return { tweens, live, commitMutation, written };
}
describe("a move on a GSAP-animated layer, at the playhead", () => {
it("adds a keyframe inside a from() tween and keeps its end where GSAP parsed it", async () => {
const { outcome, mutations } = await drag([fromX], [fromXLive(40)], {
time: 1,
base: [-10, 0],
dx: 30,
});
expect(outcome).toEqual({ status: "persisted" });
expect(mutations).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
animationId: "#box-from-0-position",
position: 0,
duration: 2,
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { x: -60, y: 0 } },
{ percentage: 50, properties: { x: 20, y: 0 } },
{ percentage: 100, properties: { x: 40, y: 0 } },
],
}),
]);
});
it("writes the end GSAP parsed from the file, not the dragged element's live value", async () => {
const { mutations } = await drag([fromX], [fromXLive(40)], {
time: 1,
base: [-10, 0],
dx: 30,
values: { x: 999 },
});
expect(mutations[0].keyframes.at(-1)).toEqual({ percentage: 100, properties: { x: 40, y: 0 } });
});
it("extends a to() tween back to the playhead, holding the start GSAP folded from CSS translate", async () => {
const toX = tween({
id: "#box-to-2000-position",
method: "to",
properties: { x: 60 },
resolvedStart: 2,
duration: 1,
ease: "none",
});
// `translate: 40px 30px` on the element: GSAP parses x from 40, not 0.
const live = liveTween(el, { start: 2, duration: 1, vars: { x: 60 }, ends: { x: [40, 60] } });
const { mutations } = await drag([toX], [live], { time: 1, base: [40, 0], dx: -20 });
expect(mutations).toEqual([
expect.objectContaining({
animationId: "#box-to-2000-position",
position: 1,
duration: 2,
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { x: 20, y: 0 } },
{ percentage: 50, properties: { x: 40, y: 0 } },
{ percentage: 100, properties: { x: 60, y: 0 } },
],
}),
]);
});
it("refuses, with the Code tab message, when GSAP has not parsed the tween's end yet", async () => {
const unparsed = liveTween(el, { start: 0, duration: 2, vars: { x: -60 } }, { from: true });
const { outcome, mutations } = await drag([fromX], [unparsed], {
time: 1,
base: [-10, 0],
dx: 30,
});
expect(outcome).toEqual({
status: "blocked",
reason: "keyframes-uneditable",
detail: "implicit-end-unknown",
});
expect(mutations).toEqual([]);
expect(GSAP_EDIT_BLOCK_COPY["keyframes-uneditable"]).toContain("Code tab");
});
it("adds a keyframe between two steps of a keyframes array", async () => {
const keys = tween({
id: "#box-to-0-position",
method: "to",
properties: {},
resolvedStart: 0,
duration: 3,
keyframes: {
format: "object-array",
keyframes: [
// The parse rounds the step to 66.7%; GSAP times it at 2 s of 3.
{ percentage: 66.7, properties: { x: 60 } },
{ percentage: 100, properties: { x: 120 } },
],
},
});
const steps = [
{ startTime: () => 0, duration: () => 2 },
{ startTime: () => 2, duration: () => 1 },
];
const live = liveTween(
el,
{ start: 0, duration: 3, vars: { keyframes: [] } },
{ parts: steps },
);
const { mutations } = await drag([keys], [live], { time: 1, base: [30, 0], dx: 10 });
expect(mutations.map((m) => m.type)).toEqual(["replace-with-keyframes"]);
// An array step eases linearly unless it says otherwise; percentage keyframes would not.
expect(mutations[0].keyframes).toEqual([
{ percentage: 33.333, properties: { x: 40, y: 0 }, ease: "none" },
{ percentage: 66.667, properties: { x: 60, y: 0 }, ease: "none" },
{ percentage: 100, properties: { x: 120, y: 0 }, ease: "none" },
]);
});
it("never writes x and y into a tween that only animates size", async () => {
const size = tween({
id: "#box-to-0-size",
propertyGroup: "size",
method: "to",
properties: {},
resolvedStart: 0,
duration: 2,
keyframes: {
format: "percentage",
keyframes: [{ percentage: 100, properties: { width: 300 } }],
},
});
const live = liveTween(el, {
start: 0,
duration: 2,
vars: { keyframes: { "100%": { width: 300 } } },
});
const { mutations } = await drag([size], [live], { time: 1, base: [0, 0], dx: 10 });
expect(mutations.some((m) => m.animationId === "#box-to-0-size")).toBe(false);
});
});
describe("where two tweens meet at the playhead", () => {
it("edits the one whose keyframe the file states there, not the one listed first", async () => {
const fromTo = tween({
id: "#box-fromTo-2000-position",
method: "fromTo",
fromProperties: { x: 0 },
properties: { x: 60 },
resolvedStart: 2,
duration: 1,
ease: "none",
});
const live = liveTween(el, { start: 2, duration: 1, vars: { x: 60 }, ends: { x: [0, 60] } });
const { mutations } = await drag([fromX, fromTo], [fromXLive(40), live], {
time: 2,
base: [0, 0],
dx: 25,
});
expect(mutations).toEqual([
expect.objectContaining({
animationId: "#box-fromTo-2000-position",
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { x: 25, y: 0 } },
{ percentage: 100, properties: { x: 60, y: 0 } },
],
}),
]);
});
});
describe("a resize on a layer whose size GSAP animates, at the playhead", () => {
it("adds a size keyframe inside the tween at the playhead", async () => {
const { tweens, live, commitMutation, written } = backToBack(
"size",
{ width: 300, height: 200 },
{ width: 360, height: 240 },
);
usePlayerStore.setState({ currentTime: 1 });
await tryGsapResizeIntercept(
selection,
{ width: 330, height: 210 },
tweens,
previewWith(el, live),
commitMutation,
);
expect(written()).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
animationId: "#box-to-0-size",
easeEach: "none",
keyframes: [
{ percentage: 50, properties: { width: 330, height: 210 } },
{ percentage: 100, properties: { width: 300, height: 200 } },
],
}),
]);
});
});
describe("a resize on a layer whose scale GSAP animates, at the playhead", () => {
it("adds a scale keyframe inside the tween at the playhead and leaves the next tween alone", async () => {
const { tweens, live, commitMutation, written } = backToBack(
"scale",
{ scale: 1.25 },
{ scale: 1.5 },
);
el.setAttribute("data-hf-studio-original-box-width", "240");
el.setAttribute("data-hf-studio-original-box-height", "160");
usePlayerStore.setState({ currentTime: 1 });
// Dropped at 1.5x the box with a live scale of 1.125, the playhead's value of the first tween.
await tryGsapResizeIntercept(
selection,
{ width: 320, height: 213.333 },
tweens,
previewWith(el, live, { scaleX: 1.125, scaleY: 1.125 }),
commitMutation,
);
const writes = written();
expect(writes[0]).toMatchObject({
type: "replace-with-keyframes",
animationId: "#box-to-0-scale",
easeEach: "none",
keyframes: [
{ percentage: 50, properties: { scale: 1.5 } },
{ percentage: 100, properties: { scale: 1.25 } },
],
});
expect(writes.some((m) => m.animationId === "#box-to-2000-scale")).toBe(false);
});
});
describe("a resize inside one gesture, on a tween that mixes size and position", () => {
it("writes the size and the anchor move once, into the tween the file has", async () => {
const keys = tween({
id: "#box-to-0",
propertyGroup: undefined,
method: "to",
properties: {},
resolvedStart: 0,
duration: 3,
keyframes: {
format: "object-array",
keyframes: [
{ percentage: 66.667, properties: { x: 60, width: 280 }, ease: "none" },
{ percentage: 100, properties: { x: 120, width: 320 }, ease: "none" },
],
},
});
el.setAttribute("data-hf-studio-original-box-width", "260");
el.setAttribute("data-hf-studio-original-box-height", "160");
el.setAttribute("data-hf-drag-gsap-base-x", "50");
el.setAttribute("data-hf-drag-gsap-base-y", "30");
const live = liveTween(el, { start: 0, duration: 3, vars: { keyframes: [] } });
usePlayerStore.setState({ currentTime: 1 });
// A gesture buffers its writes: the file the intercept reads back stays as it was.
const commitMutation = vi.fn();
const outcome = await tryGsapResizeIntercept(
selection,
{ width: 300, height: 200 },
[keys],
previewWith(el, [live]),
commitMutation,
async () => [keys],
{ x: -40, y: -40 },
);
expect(outcome).toMatchObject({ status: "persisted", ownsDragOffset: true });
expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
animationId: "#box-to-0",
keyframes: [
{
percentage: 33.333,
properties: { width: 300, height: 200, x: 10, y: -10 },
ease: "none",
},
{
percentage: 66.667,
properties: { x: 60, width: 280, height: 160, y: 30 },
ease: "none",
},
{ percentage: 100, properties: { x: 120, width: 320, height: 160, y: 30 }, ease: "none" },
],
}),
]);
});
});
describe("a rotate on a GSAP-animated layer, at the playhead", () => {
it("changes the keyframe two flat tweens meet at, on the tween that states it", async () => {
const { tweens, live, commitMutation, written } = backToBack(
"rotation",
{ rotation: 20 },
{ rotation: 40 },
);
usePlayerStore.setState({ currentTime: 2 });
await tryGsapRotationIntercept(selection, 25, tweens, previewWith(el, live), commitMutation);
expect(written()).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
animationId: "#box-to-0-rotation",
position: 0,
duration: 2,
easeEach: "none",
keyframes: [{ percentage: 100, properties: { rotation: 25 } }],
}),
]);
});
});
describe("a rotate on a layer whose tween turns it on another axis", () => {
it("holds the new rotation across the whole tween, so the layer does not turn before the playhead", async () => {
const flip = tween({
id: "#box-to-0-rotation",
propertyGroup: "rotation",
method: "to",
properties: { rotationY: 180 },
resolvedStart: 0,
duration: 2,
ease: "none",
});
usePlayerStore.setState({ currentTime: 1 });
const commitMutation = vi.fn();
const live = [liveTween(el, { start: 0, duration: 2, vars: flip.properties })];
await tryGsapRotationIntercept(selection, 45, [flip], previewWith(el, live), commitMutation);
expect(commitMutation.mock.calls.map((call) => call[1].keyframes)).toEqual([
[
{ percentage: 0, properties: { rotation: 45 } },
{ percentage: 50, properties: { rotation: 45 } },
{ percentage: 100, properties: { rotationY: 180, rotation: 45 } },
],
]);
});
});
describe("planValueAtPlayhead", () => {
const plan = (edit: Partial<PlayheadEdit>) =>
planValueAtPlayhead({
anim: tween({ method: "to", properties: {}, resolvedStart: 0, duration: 4 }),
at: { time: 2 },
values: {},
implicitEndValue: () => null,
...edit,
});
const kf = (
keyframes: Array<{ percentage: number; properties: Record<string, number> }>,
easeEach?: string,
) =>
tween({
method: "to",
properties: {},
resolvedStart: 0,
duration: 4,
keyframes: { format: "percentage", keyframes, ...(easeEach && { easeEach }) },
});
it("changes the keyframe under the playhead and no other", () => {
const result = plan({
anim: kf([
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 10 } },
{ percentage: 100, properties: { x: 20 } },
]),
values: { x: 15 },
});
expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([0, 15, 20]);
});
it("reports an add only when it writes a keyframe the tween did not have", () => {
const changed = plan({
anim: kf([
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 20 } },
]),
at: { time: 4 },
values: { x: 15 },
});
const held = plan({
anim: kf([{ percentage: 100, properties: { x: 20 } }]),
at: { time: 4 },
values: { y: 5 },
backfill: { y: 0 },
holdFromStart: true,
});
expect(changed.ok && changed.added).toBe(false);
expect(held.ok && held.mutation.keyframes.length).toBe(2);
expect(held.ok && held.added).toBe(true);
});
it("eases the new segment like the one it splits", () => {
const result = plan({
anim: kf([{ percentage: 100, properties: { x: 20 } }], "power2.in"),
at: { time: 1 },
values: { x: 5 },
});
expect(result.ok && result.mutation).toMatchObject({
easeEach: "power2.in",
keyframes: [
{ percentage: 25, properties: { x: 5 } },
{ percentage: 100, properties: { x: 20 } },
],
});
});
it("extends past the end and keeps the authored end where it was", () => {
const result = plan({
anim: tween({
method: "to",
properties: { width: 300 },
resolvedStart: 0,
duration: 2,
ease: "none",
}),
at: { time: 3 },
values: { width: 400 },
});
expect(result.ok && result.mutation).toMatchObject({
position: 0,
duration: 3,
easeEach: "none",
keyframes: [
{ percentage: 66.667, properties: { width: 300 } },
{ percentage: 100, properties: { width: 400 } },
],
});
});
it("splits a uniform scale into longhands for a per-axis edit", () => {
const result = plan({
anim: tween({
method: "to",
properties: { scale: 1.25 },
resolvedStart: 0,
duration: 4,
ease: "none",
}),
values: { scaleX: 1.5, scaleY: 1.1 },
});
expect(result.ok && result.mutation.keyframes).toEqual([
{ percentage: 50, properties: { scaleX: 1.5, scaleY: 1.1 } },
{ percentage: 100, properties: { scaleX: 1.25, scaleY: 1.25 } },
]);
});
it("refuses keyframes eased as a whole, whose segment curves it cannot keep", () => {
const anim = { ...kf([{ percentage: 100, properties: { x: 20 } }]), ease: "power2.out" };
expect(plan({ anim, values: { x: 5 } })).toEqual({ ok: false, reason: "eased-keyframes" });
});
});
it("refuses a tween whose selector also animates a sibling, before it plans anything", () => {
el.className = "card";
const sibling = document.body.appendChild(document.createElement("div"));
sibling.className = "card";
const shared = tween({
targetSelector: ".card",
properties: { x: 100 },
resolvedStart: 0,
duration: 1,
});
expect(planValueEdit(selection, shared, { x: 40 }, null)).toEqual({
ok: false,
reason: "shared-tween",
});
sibling.remove();
});
@@ -0,0 +1,292 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import {
materializeIfDynamic,
parkPlayheadOnKeyframe,
type GsapDragCommitCallbacks,
} from "./gsapDragCommit";
import type { GsapEditOutcome, PlayheadEditRefusal } from "./gsapEditOutcome";
import {
findParsedTween,
parsedImplicitEndValue,
parsedTweenEase,
withExactStepTimes,
withLiveTiming,
} from "./gsapParsedTween";
import { KEYFRAME_PCT_MATCH } from "./gsapShared";
import { tweenReach } from "./gsapTweenReach";
type Props = Record<string, number | string>;
interface Keyframe {
percentage: number;
properties: Props;
ease?: string;
auto?: boolean;
}
/** A tween's un-animated value of `prop` at its start or end, from GSAP's own parse; null when unknown. */
export type ImplicitEndValue = (prop: string, end: "start" | "end") => number | null;
export interface PlayheadEdit {
anim: GsapAnimation;
/** The playhead's composition time, or a keyframe the user selected in the lane. */
at: { time: number } | { percentage: number };
/** Every channel here must already be animated by `anim`. */
values: Record<string, number>;
implicitEndValue: ImplicitEndValue;
/** The flat tween's ease as GSAP resolved it, for a tween that authors none. */
parsedEase?: string | null;
/** A channel of the edit the tween does not animate yet, held at this value on its keyframes. */
backfill?: Record<string, number>;
/** Hold the backfill from the tween's start too, where GSAP would otherwise begin at the old value. */
holdFromStart?: boolean;
}
export type PlayheadEditPlan =
| {
ok: true;
mutation: {
type: "replace-with-keyframes";
animationId: string;
targetSelector: string;
position: number;
duration: number;
keyframes: Keyframe[];
easeEach?: string;
};
/** A keyframe was added, not only changed: keyframe usage counts it as an add. */
added: boolean;
}
| { ok: false; reason: PlayheadEditRefusal };
// GSAP 3.14 defaults: a percentage keyframe eases its segment power1.inOut; an array step is linear.
const PERCENTAGE_SEGMENT_EASE = "power1.inOut";
const ARRAY_STEP_EASE = "none";
const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;
const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease === "linear";
interface Normalized {
keyframes: Keyframe[];
easeEach?: string;
}
function refuse(reason: PlayheadEditRefusal): { ok: false; reason: PlayheadEditRefusal } {
return { ok: false, reason };
}
// fallow-ignore-next-line complexity
function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefusal } {
const { anim } = edit;
const data = anim.keyframes;
if (data) {
if (!isLinear(anim.ease) || !isLinear(data.ease)) return { reason: "eased-keyframes" };
if (data.format === "simple-array") return { reason: "simple-array-keyframes" };
const arrayStep = data.format === "object-array";
return {
keyframes: data.keyframes.map((kf) => ({
...kf,
properties: { ...kf.properties },
...(arrayStep ? { ease: kf.ease ?? ARRAY_STEP_EASE } : {}),
})),
...(data.easeEach && !arrayStep ? { easeEach: data.easeEach } : {}),
};
}
// The flat tween's ease becomes easeEach: a top-level ease would leave each segment power1.inOut.
const easeEach = anim.ease ?? edit.parsedEase;
if (!easeEach) return { reason: "unknown-ease" };
const to = { percentage: 100, properties: { ...anim.properties } };
if (anim.method === "to") return { keyframes: [to], easeEach };
if (anim.method === "fromTo") {
return { keyframes: [{ percentage: 0, properties: { ...anim.fromProperties } }, to], easeEach };
}
if (anim.method === "from") {
const end: Props = {};
for (const prop of Object.keys(anim.properties)) {
const value = edit.implicitEndValue(prop, "end");
if (value == null) return { reason: "implicit-end-unknown" };
end[prop] = value;
}
return {
keyframes: [
{ percentage: 0, properties: { ...anim.properties } },
{ percentage: 100, properties: end },
],
easeEach,
};
}
return { reason: "not-a-tween" };
}
function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") {
const carrying = keyframes.filter((kf) => kf.properties[prop] != null);
const kf = side === "first" ? carrying[0] : carrying.at(-1);
return kf ? { percentage: kf.percentage, value: kf.properties[prop]! } : null;
}
function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) {
const hit = keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH);
if (hit) {
Object.assign(hit.properties, properties);
return hit;
}
const added = { percentage, properties, ...(ease ? { ease } : {}) };
keyframes.push(added);
return added;
}
/** Every channel the tween animates, held at its value at the tween's authored start or end. */
function heldEnds(
norm: Normalized,
end: "start" | "end",
implicit: ImplicitEndValue,
backfilled: Record<string, number>,
) {
const props = new Set(norm.keyframes.flatMap((kf) => Object.keys(kf.properties)));
const held: Props = {};
for (const prop of props) {
const known = valueAt(norm.keyframes, prop, end === "start" ? "first" : "last");
// A channel holds its last keyframe after it; only a start can be implicit.
const value =
known && (end === "end" || known.percentage <= 0)
? known.value
: (backfilled[prop] ?? implicit(prop, "start"));
if (value == null) return null;
held[prop] = value;
}
return held;
}
/** On a keyframe, change it; between two, add one at the playhead; outside the tween, add one there
* and keep the authored ends. Values come from the file's tween or GSAP's parse, never the DOM. */
// fallow-ignore-next-line complexity
export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
const { anim, values } = edit;
const start = resolveTweenStart(anim);
const duration = resolveTweenDuration(anim);
if (start == null || !(duration > 0)) return refuse("no-timing");
const norm = normalize(edit);
if ("reason" in norm) return refuse(norm.reason);
const keyframes = norm.keyframes;
const authored = keyframes.length;
// GSAP runs `scale` beside scaleX/scaleY and the longhands win, so a per-axis edit splits it.
if ("scaleX" in values || "scaleY" in values) {
for (const kf of keyframes) {
const { scale, ...rest } = kf.properties;
if (scale != null) kf.properties = { scaleX: scale, scaleY: scale, ...rest };
}
}
const backfilled: Record<string, number> = {};
for (const [prop, value] of Object.entries(edit.backfill ?? {})) {
if (!(prop in values) || keyframes.some((kf) => kf.properties[prop] != null)) continue;
if (edit.holdFromStart && !keyframes.some((kf) => kf.percentage <= 0))
keyframes.unshift({ percentage: 0, properties: {} });
for (const kf of keyframes) kf.properties[prop] = value;
backfilled[prop] = value;
}
if (Object.keys(values).some((prop) => !keyframes.some((kf) => kf.properties[prop] != null)))
return refuse("implicit-end-unknown");
let position = start;
let span = duration;
const pct =
"percentage" in edit.at
? edit.at.percentage
: roundPct(((edit.at.time - start) / duration) * 100);
if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) {
const at = Math.min(100, Math.max(0, pct));
const next = [...keyframes]
.sort((a, b) => a.percentage - b.percentage)
.find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH);
// The new keyframe splits next's segment, so it takes next's ease.
upsert(keyframes, at, { ...values }, next?.ease);
} else {
const time = (edit.at as { time: number }).time;
const before = time < start;
const held = heldEnds(norm, before ? "start" : "end", edit.implicitEndValue, backfilled);
if (!held) return refuse("implicit-end-unknown");
position = before ? time : start;
span = before ? start + duration - time : time - start;
for (const kf of keyframes) {
kf.percentage = roundPct(
((start + (kf.percentage / 100) * duration - position) / span) * 100,
);
}
const oldEdge = roundPct((((before ? start : start + duration) - position) / span) * 100);
// The span the tween gains is new and linear; the authored segments keep their eases.
const linear = isLinear(norm.easeEach ?? PERCENTAGE_SEGMENT_EASE) ? undefined : "none";
const edge = upsert(keyframes, oldEdge, held, linear);
if (before && linear) edge.ease ??= linear;
upsert(keyframes, before ? 0 : 100, { ...held, ...values }, before ? undefined : linear);
}
keyframes.sort((a, b) => a.percentage - b.percentage);
return {
ok: true,
mutation: {
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: anim.targetSelector,
position: roundTo3(position),
duration: roundTo3(span),
keyframes,
...(norm.easeEach ? { easeEach: norm.easeEach } : {}),
},
added: keyframes.length > authored,
};
}
/** commitValueAtPlayhead's plan, writing nothing, so a group can plan every member first. */
export function planValueEdit(
selection: DomEditSelection,
anim: GsapAnimation,
values: Record<string, number>,
iframe: HTMLIFrameElement | null,
{ backfill, holdFromStart }: Pick<PlayheadEdit, "backfill" | "holdFromStart"> = {},
): PlayheadEditPlan {
// One keyframe of a tween its siblings share would move them all.
if (tweenReach(anim, selection.element) === "shared") return refuse("shared-tween");
const { activeKeyframePct, currentTime } = usePlayerStore.getState();
const tween = findParsedTween(iframe, selection.element, anim);
const timed = withExactStepTimes(anim, tween);
return planValueAtPlayhead({
anim: withLiveTiming(timed, tween),
at: activeKeyframePct != null ? { percentage: activeKeyframePct } : { time: currentTime },
values,
backfill,
holdFromStart,
implicitEndValue: parsedImplicitEndValue(tween),
parsedEase: parsedTweenEase(iframe, tween),
});
}
/** Writes `values` into `anim` at the playhead (or the keyframe selected in the lane). */
export async function commitValueAtPlayhead(
selection: DomEditSelection,
anim: GsapAnimation,
values: Record<string, number>,
iframe: HTMLIFrameElement | null,
callbacks: GsapDragCommitCallbacks,
options: Pick<PlayheadEdit, "backfill" | "holdFromStart"> & {
label: string;
beforeReload?: () => void;
},
): Promise<GsapEditOutcome> {
await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const plan = planValueEdit(selection, anim, values, iframe, options);
if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
await callbacks.commitMutation(selection, plan.mutation, {
label: options.label,
softReload: true,
beforeReload: options.beforeReload,
...(plan.added && { keyframeAction: "add" as const }),
});
if (activeKeyframePct != null) {
setActiveKeyframePct(null);
parkPlayheadOnKeyframe(anim, activeKeyframePct);
}
return { status: "persisted" };
}
@@ -26,6 +26,29 @@ function recordingCallbacks(): {
};
}
/** Rotates a 2s `#box` tween made of `fields` to 50deg at its end; returns what was committed. */
async function rotateTo50AtEnd(fields: Record<string, unknown>) {
const anim = {
id: "#box-rotate",
targetSelector: "#box",
method: "to",
resolvedStart: 0,
duration: 2,
...fields,
} as unknown as GsapAnimation;
const { mutations, callbacks } = recordingCallbacks();
await commitWholePropertyOffset(
selection(),
anim,
{ rotation: 50 },
100,
null,
callbacks,
"Rotate",
);
return mutations;
}
describe("commitWholePropertyOffset", () => {
it("shifts every keyframe of a keyframed tween by the delta at the nearest keyframe", async () => {
const anim = {
@@ -159,4 +182,24 @@ describe("commitWholePropertyOffset", () => {
{ type: "update-properties", animationId: "#box-hold", properties: { width: 300 } },
]);
});
it("keeps a linear flat tween linear: its ease becomes the keyframes' easeEach", async () => {
// A tween-level ease leaves each percentage segment power1.inOut, so the box drifts mid-segment.
const mutations = await rotateTo50AtEnd({ ease: "none", properties: { rotation: 40 } });
expect(mutations[0]).toMatchObject({ type: "replace-with-keyframes", easeEach: "none" });
expect(mutations[0]).not.toHaveProperty("ease", "none");
});
it("keeps a keyframed tween's easeEach", async () => {
const mutations = await rotateTo50AtEnd({
keyframes: {
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { rotation: 10 } },
{ percentage: 100, properties: { rotation: 40 } },
],
},
});
expect(mutations[0]).toMatchObject({ easeEach: "none" });
});
});
@@ -6,7 +6,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { PROPERTY_DEFAULTS } from "./gsapShared";
import { keyframeEases, PROPERTY_DEFAULTS } from "./gsapShared";
import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
import { materializeIfDynamic, type GsapDragCommitCallbacks } from "./gsapDragCommit";
@@ -38,7 +38,6 @@ export async function commitWholePropertyOffset(
const ts = resolveTweenStart(effectiveAnim);
const td = resolveTweenDuration(effectiveAnim);
const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease;
const keys = Object.keys(newValues);
const at = (props: Record<string, number | string>, key: string) =>
typeof props[key] === "number" ? (props[key] as number) : (PROPERTY_DEFAULTS[key] ?? 0);
@@ -79,7 +78,7 @@ export async function commitWholePropertyOffset(
position: roundTo3(ts ?? 0),
duration: roundTo3(td || 1),
keyframes: shifted,
ease,
...keyframeEases(effectiveAnim),
},
{ label, softReload: true },
);
@@ -14,6 +14,7 @@ import {
synthesizeFlatTweenKeyframes,
type MergeableKeyframe,
} from "./gsapTweenSynth";
import { studioApiFetch } from "../utils/studioApiFetch";
export { resolveSelectorElementIds };
@@ -79,7 +80,7 @@ async function requestParsedAnimations(
): Promise<ParsedGsapAnimations | null> {
if (!isPreviewBooted(projectId) && !(await whenPreviewBooted(projectId))) return null;
try {
const res = await fetch(
const res = await studioApiFetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
// Always revalidate; an unchanged file answers 304. No per-call timestamp
// (a deterministic-render no-no).
@@ -13,57 +13,30 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { parseGsapScript } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
import { mountGroupSiblings, stableSelectionFor } from "./domSelectionTestHarness";
import { resolveSelectorElementIds } from "./gsapShared";
import { ensureElementAddressable } from "./gsapScriptCommitHelpers";
import {
commitStaticGsapPosition,
commitStaticGsapRotation,
commitStaticGsapSize,
commitKeyframedSizeFromResize,
commitWholePathOffset,
findExistingPositionWrite,
} from "./gsapDragCommit";
import { promoteSetToKeyframes } from "./useEnableKeyframes";
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
afterEach(() => {
document.body.innerHTML = "";
});
function selectionFor(el: HTMLElement): DomEditSelection {
const selector = buildStableSelector(el);
return {
element: el,
id: el.id || undefined,
hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
sourceFile: "index.html",
dataAttributes: { start: "0", duration: "2" },
} as unknown as DomEditSelection;
}
/** Five class-only siblings, each with an id so attribution is nameable. */
function mountGroupSiblings(): HTMLElement[] {
document.body.innerHTML = `
<div id="scene" class="clip" data-start="0" data-duration="2">
<div class="group" id="group-0"></div>
<div class="group" id="group-1"></div>
<div class="group" id="group-2"></div>
<div class="group" id="group-3"></div>
<div class="group" id="group-4"></div>
</div>
`;
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
}
/**
* The selection production hands these writers: the element HAS an id in the
* DOM (so resolveSelectorElementIds can name it) but the SELECTION carries none,
* which is the id-less shape buildStableSelector answers with a bare class.
*/
function classOnlySelection(el: HTMLElement): DomEditSelection {
return { ...selectionFor(el), id: undefined, selector: ".group" } as DomEditSelection;
return { ...stableSelectionFor(el), id: undefined, selector: ".group" } as DomEditSelection;
}
/** The elements a written targetSelector actually attributes the tween to. */
@@ -94,7 +67,7 @@ describe("ensureElementAddressable — add-animation button", () => {
</div>
`;
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
const selection = selectionFor(el);
const selection = stableSelectionFor(el);
expect(selection.selector).toBe(".group");
const { selector, autoId } = ensureElementAddressable(selection);
@@ -109,20 +82,20 @@ describe("ensureElementAddressable — add-animation button", () => {
document.body.innerHTML = `<div id="box" class="card"></div>`;
const el = document.querySelector<HTMLElement>("#box")!;
expect(ensureElementAddressable(selectionFor(el)).selector).toBe("#box");
expect(ensureElementAddressable(stableSelectionFor(el)).selector).toBe("#box");
});
it("keeps an already-unique class selector as authored", () => {
document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
const el = document.querySelector<HTMLElement>(".header")!;
expect(ensureElementAddressable(selectionFor(el)).selector).toBe(".header");
expect(ensureElementAddressable(stableSelectionFor(el)).selector).toBe(".header");
});
it("still mints an id when there is no live element to disambiguate against", () => {
document.body.innerHTML = `<div id="scene"><div></div></div>`;
const el = document.querySelector<HTMLElement>("#scene > div")!;
const selection = { ...selectionFor(el), selector: undefined } as DomEditSelection;
const selection = { ...stableSelectionFor(el), selector: undefined } as DomEditSelection;
const { selector, autoId } = ensureElementAddressable(selection);
@@ -131,25 +104,28 @@ describe("ensureElementAddressable — add-animation button", () => {
});
});
/** Moves the middle of five class-only siblings with no position yet; returns the selector written. */
async function positionMiddleSibling(): Promise<string> {
const groups = mountGroupSiblings(5, true);
const { mutations, callbacks } = recorder();
await commitStaticGsapPosition(
classOnlySelection(groups[2]!),
{ x: 10, y: 10 },
{ x: 0, y: 0 },
".group",
null,
callbacks,
);
return writtenTargets(mutations)[0]!;
}
describe("gsapDragCommit — new-tween targets", () => {
it("commitStaticGsapPosition authors the new set against one element", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
await commitStaticGsapPosition(
classOnlySelection(groups[2]!),
{ x: 10, y: 10 },
{ x: 0, y: 0 },
".group",
null,
callbacks,
);
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-2"]);
expect(attributedTo(await positionMiddleSibling())).toEqual(["group-2"]);
});
it("commitStaticGsapRotation authors the new set against one element", async () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5, true);
const { mutations, callbacks } = recorder();
await commitStaticGsapRotation(classOnlySelection(groups[1]!), 42, ".group", null, callbacks);
@@ -158,7 +134,7 @@ describe("gsapDragCommit — new-tween targets", () => {
});
it("commitStaticGsapSize authors the new set against one element", async () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5, true);
const { mutations, callbacks } = recorder();
await commitStaticGsapSize(
@@ -172,34 +148,34 @@ describe("gsapDragCommit — new-tween targets", () => {
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-4"]);
});
it("commitKeyframedSizeFromResize authors the new keyframe tween against one element", async () => {
const groups = mountGroupSiblings();
it("a keyframe edit refuses a tween its siblings share instead of moving them all", async () => {
const groups = mountGroupSiblings(5, true);
const { mutations, callbacks } = recorder();
const animatedTween = {
const sharedTween = {
id: "t1",
targetSelector: ".group",
method: "to",
properties: {},
properties: { width: 120 },
resolvedStart: 0,
duration: 2,
keyframes: { keyframes: [{ percentage: 0, properties: { x: 0 } }] },
ease: "none",
} as unknown as GsapAnimation;
const handled = await commitKeyframedSizeFromResize(
const outcome = await commitValueAtPlayhead(
classOnlySelection(groups[3]!),
{ width: 80, height: 40 },
".group",
sharedTween,
{ width: 80 },
null,
animatedTween,
callbacks,
{ label: "Resize" },
);
expect(handled).toBe(true);
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-3"]);
expect(outcome).toMatchObject({ status: "blocked", detail: "shared-tween" });
expect(mutations).toEqual([]);
});
it("commitStaticGsapPosition replaces a corrupt keyframed hold against one element", async () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5, true);
const { mutations, callbacks } = recorder();
const corruptHold = {
id: "hold-1",
@@ -225,7 +201,7 @@ describe("gsapDragCommit — new-tween targets", () => {
describe("gsapDragCommit — retargeting an existing tween is left alone", () => {
it("commitWholePathOffset keeps the tween's own group target", async () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5, true);
const { mutations, callbacks } = recorder();
const groupTween = {
id: "t-group",
@@ -261,7 +237,7 @@ describe("gsapDragCommit — retargeting an existing tween is left alone", () =>
*/
describe("a re-nudge updates its own previous write instead of stacking a second one", () => {
it("finds the write the first nudge authored", async () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5, true);
const selection = classOnlySelection(groups[2]!);
const first = recorder();
@@ -309,7 +285,7 @@ gsap.set(${JSON.stringify(written)}, { x: 10, y: 10 });
*/
describe("useEnableKeyframes — rewriting an existing tween keeps its group target", () => {
it("promoteSetToKeyframes leaves a group-authored set aimed at the group", async () => {
const groups = mountGroupSiblings();
const groups = mountGroupSiblings(5, true);
const mutations: Array<Record<string, unknown>> = [];
const setAnim = {
id: "set-group",
@@ -339,18 +315,7 @@ describe("useEnableKeyframes — rewriting an existing tween keeps its group tar
describe("the written selector survives the real writer and parser", () => {
it("re-parses to a tween attributed to the one element it targeted", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
await commitStaticGsapPosition(
classOnlySelection(groups[2]!),
{ x: 10, y: 10 },
{ x: 0, y: 0 },
".group",
null,
callbacks,
);
const written = writtenTargets(mutations)[0]!;
const written = await positionMiddleSibling();
const script = `
const tl = gsap.timeline({ paused: true });
@@ -593,6 +593,7 @@ describe("a timing edit's soft reload restores what GSAP wrote from the files",
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/files/compositions%2Fsub.html"),
{ credentials: "omit" },
);
expect(reloadPreview).not.toHaveBeenCalled();
expect(wt.getAttribute("style")).toBe("left: 700px;");
@@ -28,12 +28,13 @@ import {
serializeStudioFileMutations,
type StudioProjectFileWriter,
} from "../utils/studioFileMutationCoordinator";
import { studioApiFetch } from "../utils/studioApiFetch";
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
if (targetPath.includes("\0") || targetPath.includes("..")) {
throw new Error(`Unsafe path: ${targetPath}`);
}
const response = await fetch(
const response = await studioApiFetch(
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) {
@@ -7,6 +7,7 @@ import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/stud
import { liveMarkupWithoutPreviewMarks } from "../utils/authoredSource";
import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
import { usePlayerStore } from "../player";
import { studioApiFetch } from "../utils/studioApiFetch";
// ── Types ──
@@ -55,7 +56,7 @@ export function useAskAgentModal({
const targetPath = selection.sourceFile || activeCompPath || "index.html";
try {
const response = await fetch(
const response = await studioApiFetch(
buildProjectApiPath(pid, `/files/${encodeURIComponent(targetPath)}`),
);
if (!response.ok) return;
+2 -1
View File
@@ -5,6 +5,7 @@ import {
type BlockCategory,
resolveBlockCategory,
} from "../utils/blockCategories";
import { studioApiFetch } from "../utils/studioApiFetch";
type CatalogItem = RegistryItem & {
category: BlockCategory;
@@ -14,7 +15,7 @@ let catalogCache: CatalogItem[] | null = null;
let catalogRequest: Promise<CatalogItem[]> | null = null;
function loadCatalog(): Promise<CatalogItem[]> {
catalogRequest ??= fetch("/api/registry/blocks")
catalogRequest ??= studioApiFetch("/api/registry/blocks")
.then((response) => {
if (!response.ok) throw new Error("Failed to load catalog");
return response.json() as Promise<RegistryItem[]>;
@@ -5,6 +5,7 @@ import { useCaptionStore } from "../captions/store";
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
import { useCaptionSync } from "../captions/hooks/useCaptionSync";
import { parseCaptionComposition } from "../captions/parser";
import { studioApiFetch } from "../utils/studioApiFetch";
interface UseCaptionDetectionParams {
projectId: string | null;
@@ -110,7 +111,7 @@ export function useCaptionDetection({
activating = true;
const srcPath = captionSrcPath;
fetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(srcPath)}`))
studioApiFetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(srcPath)}`))
.then((r) => r.json())
.then((data: { content?: string }) => {
if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
@@ -1,5 +1,6 @@
import { buildProjectApiPath } from "../utils/projectRouting";
import { useCallback } from "react";
import { studioApiFetch } from "../utils/studioApiFetch";
/**
* Loads a composition file's content for the source editor when a composition
@@ -24,7 +25,7 @@ export function useCompositionContentLoader({
if (!projectId) return;
setActiveCompPath(comp.endsWith(".html") ? comp : null);
setEditingFile({ path: comp, content: null });
fetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(comp)}`))
studioApiFetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(comp)}`))
.then(async (r) => {
if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
return r.json();
@@ -7,6 +7,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
import type { DomEditPatchBatch } from "./domEditCommitTypes";
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
import { studioWriteHeaders } from "../utils/studioFileVersion";
import { studioApiFetch } from "../utils/studioApiFetch";
export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
return fields.map((field) => field.path).join(", ");
@@ -99,7 +100,7 @@ function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFi
export async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
const body = JSON.stringify({ batches });
try {
const response = await fetch(
const response = await studioApiFetch(
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
{
method: "POST",
@@ -178,7 +179,7 @@ export async function postPatchElement(
body: unknown,
showToast: ShowToast,
): Promise<PatchElementResponse> {
const response = await fetch(
const response = await studioApiFetch(
buildProjectApiPath(
projectId,
`/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
@@ -10,6 +10,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { TimelineElement } from "../player";
import type { UseDomEditSessionParams } from "./useDomEditSession";
import { makeSelection } from "./domSelectionTestHarness";
import { readTranslatePx } from "../components/editor/plainTranslate";
const styleOp = (property: string, value: string): PatchOperation => ({
type: "inline-style",
@@ -66,6 +67,7 @@ const groupSelectionSpy = vi.fn();
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
const neverParsed = new Promise<{ animations: GsapAnimation[] }>(() => undefined);
const parsedFile = { current: neverParsed };
const groupCommitSpy = vi.fn(async () => {});
function createSessionParams(
overrides: Partial<UseDomEditSessionParams> = {},
@@ -256,7 +258,7 @@ vi.mock("./usePreviewInteraction", () => ({
vi.mock("./useGsapAwareEditing", () => ({
useGsapAwareEditing: () => ({
handleGsapAwarePathOffsetCommit: vi.fn(),
handleGsapAwareGroupPathOffsetCommit: vi.fn(),
handleGsapAwareGroupPathOffsetCommit: groupCommitSpy,
handleGsapAwareBoxSizeCommit: vi.fn(),
handleGsapAwareRotationCommit: vi.fn(),
commitAnimatedProperty: vi.fn(),
@@ -605,3 +607,30 @@ describe("a shadow reload promoted without a host re-render", () => {
act(() => root.unmount());
});
});
describe("handleDomGroupMoveBy", () => {
it("saves through the group drag's commit, leaving a refusal for the host to explain", async () => {
const { useDomEditSession } = await import("./useDomEditSession");
const element = document.createElement("div");
document.body.append(element);
element.getBoundingClientRect = () => {
const { x, y } = readTranslatePx(element);
return new DOMRect(x, y, 40, 20);
};
const selection = makeSelection("card", element);
let moveBy!: ReturnType<typeof useDomEditSession>["handleDomGroupMoveBy"];
function Probe() {
moveBy = useDomEditSession(createSessionParams()).handleDomGroupMoveBy;
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(<Probe />));
await act(() => moveBy([{ selection, delta: { x: 10, y: 0 } }]));
expect(groupCommitSpy).toHaveBeenCalledWith(
[{ selection, next: { x: 10, y: 0 }, plainTranslate: true }],
{ refusalToast: false },
);
act(() => root.unmount());
element.remove();
});
});
@@ -25,6 +25,7 @@ import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMemb
import type { RecordEditInput } from "./domEditDeleteMembers";
import type { DomEditTimelineParams } from "./useDomSelectionTypes";
import { useLivePreviewIframe } from "./useLivePreviewIframe";
import { moveDomGroupBy, type DomGroupMove } from "../components/editor/domGroupMoveBy";
// Re-exported: the delete rule lives in its own module; callers and its test import it from here.
export { membersForDelete };
@@ -445,6 +446,7 @@ export function useDomEditSession({
domEditSelection,
selectedGsapAnimations,
gsapCommitMutation,
activeCompPath,
previewIframeRef,
showToast,
bumpGsapCache,
@@ -459,6 +461,13 @@ export function useDomEditSession({
setArcPath,
updateArcSegment,
});
const handleDomGroupMoveBy = useCallback(
(moves: DomGroupMove[]) =>
moveDomGroupBy(moves, (updates) =>
handleGsapAwareGroupPathOffsetCommit(updates, { refusalToast: false }),
),
[handleGsapAwareGroupPathOffsetCommit],
);
const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } =
useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef });
const committable = useCommitPreflightCapabilities({
@@ -497,6 +506,7 @@ export function useDomEditSession({
handleDomAttributeBatchCommit,
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
handleDomGroupMoveBy,
handleDomZIndexReorderCommit,
handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
handleDomRotationCommit: handleGsapAwareRotationCommit,
@@ -350,6 +350,29 @@ describe("useDomEditTextCommits", () => {
expect(outcome).toEqual({ ok: true, persistence });
});
it("saves a style map on a selection as one patch, so it is one undo step", async () => {
const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
const persist = vi.fn().mockResolvedValue({ sourceFile: "index.html", changed: true });
const hook = renderTextCommitHook(
commitParams({
previewIframeRef: { current: iframe },
domEditSelection: null,
persistDomEditOperations: persist,
}),
);
await act(async () => {
await hook.handleDomStyleCommitForSelection(selectionFor(element), {
"border-width": "4px",
"border-style": "solid",
});
});
expect(persist).toHaveBeenCalledTimes(1);
expect(element.style.borderWidth).toBe("4px");
expect(element.style.borderStyle).toBe("solid");
});
it("declines a style commit with no selection, without reaching the writer", async () => {
const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
const hook = renderTextCommitHook(
@@ -109,7 +109,11 @@ export function useDomEditTextCommits({
});
const handleDomStyleCommitForSelection = useCallback(
(selection: DomEditSelection, property: string, value: string): Promise<DomEditCommitOutcome> =>
(
selection: DomEditSelection,
propertyOrStylesAsOnePatch: string | Record<string, string>,
value = "",
): Promise<DomEditCommitOutcome> =>
commitDomStyles(
{
activeCompPath,
@@ -121,7 +125,9 @@ export function useDomEditTextCommits({
resync: refreshDomEditSelectionFromPreview,
},
selection,
{ [property]: value },
typeof propertyOrStylesAsOnePatch === "string"
? { [propertyOrStylesAsOnePatch]: value }
: propertyOrStylesAsOnePatch,
),
[
activeCompPath,
@@ -128,6 +128,7 @@ export function useDomGeometryCommit({
domEditSelection: null,
selectedGsapAnimations: NO_SELECTED_ANIMATIONS,
gsapCommitMutation: gsap.commitMutation,
activeCompPath,
previewIframeRef: iframeRef,
showToast,
bumpGsapCache,
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi, type Mock } from "vitest";
import {
createStudioApi,
openProjectHistory,
@@ -168,6 +168,20 @@ it("an undo pressed while a save is still running waits for the server instead o
expect(s.box()).toBe("10px");
});
it("an undo waits for a save queued before its key, not one queued after", async () => {
const s = await studio();
await s.edit();
let land!: () => void;
void s.persistence().queueDomEditSave(() => new Promise<void>((resolve) => (land = resolve)));
await vi.waitFor(() => expect(land).toBeTypeOf("function"));
const undone = s.actions().undo();
void s.persistence().queueDomEditSave(() => new Promise<void>(() => {}));
land();
await act(() => undone);
expect(s.file()).toBe(BEFORE);
});
it("an undo after an outside write ends with the preview showing the file the server restored", async () => {
const s = await studio();
await s.edit();
@@ -202,10 +216,8 @@ function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void
return null;
}
it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
const s = await studio();
await s.edit();
resetNudgeKeys();
/** A nudge save that waits for `finish()`, then writes and records the nudge as the real save does. */
function queuedNudgeSave(s: Awaited<ReturnType<typeof studio>>) {
let finish!: () => void;
const save = vi.fn(async () => {
await new Promise<void>((resolve) => (finish = resolve));
@@ -215,6 +227,24 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
files: { "index.html": { before: AFTER, after: NUDGED } },
});
});
return { save, finish: () => finish() };
}
/** Mounts the nudge, presses ArrowRight once and waits for its save; returns the nudge's translate reader. */
async function pressNudge(s: Awaited<ReturnType<typeof studio>>, save: Mock<() => Promise<void>>) {
s.mount(createElement(Nudge, { target: s.element("box"), save }));
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
});
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
return () => s.element("box").style.getPropertyValue("translate");
}
it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
const s = await studio();
await s.edit();
resetNudgeKeys();
const { save, finish } = queuedNudgeSave(s);
s.mount(createElement(Nudge, { target: s.element("box"), save }));
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
@@ -240,21 +270,8 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
const s = await studio();
await s.edit();
resetNudgeKeys();
let finish!: () => void;
const save = vi.fn(async () => {
await new Promise<void>((resolve) => (finish = resolve));
writeFileSync(s.path, NUDGED);
await s.history().recordEdit({
label: "Move layer",
files: { "index.html": { before: AFTER, after: NUDGED } },
});
});
s.mount(createElement(Nudge, { target: s.element("box"), save }));
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
});
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
const nudge = () => s.element("box").style.getPropertyValue("translate");
const { save, finish } = queuedNudgeSave(s);
const nudge = await pressNudge(s, save);
expect(nudge()).not.toBe("");
const undone = s.actions().undo();
@@ -267,6 +284,51 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
expect(s.box()).toBe("50px");
});
/** A nudge whose save is queued and then rejects, and the history requests the undo sends. */
async function failingNudge(s: Awaited<ReturnType<typeof studio>>) {
resetNudgeKeys();
let fail!: () => void;
const save = vi.fn(
() => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
);
const nudge = await pressNudge(s, save);
const steps: string[] = [];
const real = globalThis.fetch;
vi.stubGlobal("fetch", (url: string, init?: RequestInit) => {
if (/\/history\/(step|undo)$/.test(url)) steps.push(url);
return real(url, init);
});
return { fail: () => fail(), steps, nudge };
}
it("an undo pressed while a nudge's save fails keeps the nudge undone and steps no older edit", async () => {
const s = await studio();
await s.edit();
const n = await failingNudge(s);
expect(n.nudge()).not.toBe("");
const undone = s.actions().undo();
expect(n.nudge()).toBe("");
n.fail();
await act(() => undone);
expect(n.steps).toEqual([]);
expect(s.file()).toBe(AFTER);
expect(s.box()).toBe("50px");
expect(n.nudge()).toBe("");
});
it("an undo pressed while the only edit's save fails leaves the screen as the file, nothing put back", async () => {
const s = await studio();
s.show(BEFORE);
const n = await failingNudge(s);
const undone = s.actions().undo();
n.fail();
await act(() => undone);
expect(s.file()).toBe(BEFORE);
expect(n.nudge()).toBe("");
});
it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
const s = await studio();
await s.edit();
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -30,6 +31,7 @@ function mount(
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
predict: () => predicted ?? null,
claims: () => 7,
};
const putBack = vi.fn();
const deps = {
@@ -60,6 +62,19 @@ const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored:
const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
describe("useEditHistoryActions", () => {
it("asks for the edit claimed after the key's claim count only when an edit was saving at the key", async () => {
const { deps, actions } = mount({ ok: false, reason: "empty" });
await act(() => actions.undo());
expect(deps.editHistory.undo.mock.calls[0]![0].claimedAfter).toBeUndefined();
let land!: () => void;
trackStudioPendingEdit(new Promise<void>((resolve) => (land = resolve)));
const undone = actions.undo();
land();
await act(() => undone);
expect(deps.editHistory.undo.mock.calls[1]![0].claimedAfter).toBe(7);
});
it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
const { deps, actions } = mount(
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
@@ -3,7 +3,10 @@ import { useCallback, useMemo } from "react";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
import type { RestoreFiles } from "../utils/gsapUndoRestore";
import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits";
import {
hasStudioPendingEdits,
paintBackNewestStudioPendingEdit,
} from "../utils/studioPendingEdits";
interface HistoryResult {
ok: boolean;
@@ -18,11 +21,13 @@ interface HistoryResult {
interface HistoryFileCallbacks {
readFile: (path: string) => Promise<string>;
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
claimedAfter?: number;
}
export interface EditHistoryHandle {
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
claims?: () => number;
state: {
undo: ReadonlyArray<{ createdAt: number }>;
redo: ReadonlyArray<{ createdAt: number }>;
@@ -30,7 +35,7 @@ export interface EditHistoryHandle {
}
export interface UseEditHistoryActionsOptions {
editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict">;
editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict" | "claims">;
readOptionalProjectFile: (path: string) => Promise<string>;
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
@@ -77,22 +82,28 @@ export function useEditHistoryActions({
const predicted = editHistory.predict?.(direction) ?? null;
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
const pendingEditShown =
!predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null;
const putBack = predictedShown ?? pendingEditShown;
!predictedShown && direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
const putBack = predictedShown ?? pendingEditShown?.showAgain;
const claimedAfter =
direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : undefined;
let result: HistoryResult = { ok: false, reason: "failed" };
let serverSteppedShown = false;
let revertIsTheUndo = false;
try {
await waitForPendingDomEditSaves();
revertIsTheUndo = Boolean(pendingEditShown && !(await pendingEditShown.landed()));
if (revertIsTheUndo) return;
result = await editHistory[direction]({
readFile: readHistoryFile,
serialize: serializeHistoryFiles,
claimedAfter,
});
const stepped = Boolean(result.ok && result.label);
serverSteppedShown = predictedShown
? stepped && result.undoes === predicted?.id
: stepped && Boolean(pendingEditShown);
} finally {
if (putBack && !serverSteppedShown) putBack();
if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
}
if (!result.ok && result.reason === "content-mismatch") {
showToast(
@@ -24,6 +24,7 @@ import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditComm
import { domEditCommitDeclined, type DomEditCommitOutcome } from "./domEditCommitRunner";
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
import { studioWriteHeaders } from "../utils/studioFileVersion";
import { studioApiFetch } from "../utils/studioApiFetch";
interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
/** Route delete through SDK when session resolves the hf-id. */
@@ -156,7 +157,7 @@ export function useElementLifecycleOps({
writeFile: writeProjectFile,
recordEdit: editHistory.recordEdit,
rewrite: async (originalContent) => {
const removeResponse = await fetch(
const removeResponse = await studioApiFetch(
buildProjectApiPath(
pid,
`/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
@@ -378,7 +378,10 @@ function makeElementSelection(): DomEditSelection {
} as DomEditSelection;
}
function stubFlatTweenConversion(id: string): {
function stubFlatTweenConversion(
id: string,
easeEach?: string,
): {
flat: GsapAnimation;
converted: GsapAnimation;
} {
@@ -393,6 +396,7 @@ function stubFlatTweenConversion(id: string): {
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 10 } },
],
...(easeEach && { easeEach }),
},
});
flatTweenResponses(flat, converted);
@@ -437,6 +441,33 @@ describe("useEnableKeyframes — flat tween transaction", () => {
expect(convertOptions?.coalesceKey).toEqual(expect.any(String));
});
it("keeps the converted tween's easeEach when it extends it to the playhead", async () => {
window.location.hash = "#/project/test-project";
usePlayerStore.setState({ currentTime: 3 });
const { flat } = stubFlatTweenConversion("flat-1", "none");
const commitMutation = vi.fn(async () => undefined);
const enable = renderEnableKeyframes({
domEditSelection: makeElementSelection(),
selectedGsapAnimations: [flat],
previewIframeRef: {
current: {
contentWindow: { gsap: { getProperty: () => 10 } },
} as unknown as HTMLIFrameElement,
},
handleGsapAddAnimation: vi.fn(),
handleGsapConvertToKeyframes: vi.fn(async () => undefined),
handleGsapRemoveKeyframe: vi.fn(),
commitMutation,
});
await act(async () => enable());
expect(commitMutation.mock.calls[0]?.[0]).toMatchObject({
type: "replace-with-keyframes",
easeEach: "none",
});
});
it("passes the convert coalesce key to an inside-range batch edit", async () => {
window.location.hash = "#/project/test-project";
usePlayerStore.setState({ currentTime: 1.5 });
@@ -15,6 +15,7 @@ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCa
import {
existingTweenTargetSelector,
computeElementPercentage,
keyframeEases,
KEYFRAME_PCT_MATCH,
isInstantHold,
resolveEditableTweenDuration,
@@ -266,7 +267,7 @@ async function extendKeyframedTweenToPlayhead(
position: extended.position,
duration: extended.duration,
keyframes: extended.keyframes,
ease: anim.ease,
...keyframeEases(anim),
},
{
label: "Add keyframe",
@@ -83,6 +83,25 @@ describe("external file change coordinator", () => {
expect(captured.handle?.blocked).toBeNull();
});
it("keeps one file-change subscription across re-renders and delivers to the latest callbacks", async () => {
const on = vi.fn((_event: string, next: HotHandler) => void (handler = next));
vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", { on, off: vi.fn() });
const { options } = await mountCoordinator();
on.mockClear();
const root = createRoot(document.createElement("div"));
roots.push(root);
function Probe({ onAccepted }: { onAccepted: () => void }) {
useExternalFileChangeCoordinator({ ...options, onAcceptedPersistedFileChange: onAccepted });
return null;
}
const latest = vi.fn();
await act(async () => root.render(<Probe onAccepted={vi.fn()} />));
await act(async () => root.render(<Probe onAccepted={latest} />));
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
expect(on).toHaveBeenCalledOnce();
expect(latest).toHaveBeenCalledOnce();
});
// The file tree (useFileTree) is only ever refreshed from Studio's own file
// operations (create/delete/rename/upload) — never on an external change.
// Without this call, an agent removing or replacing a composition updates
@@ -448,9 +448,11 @@ export function useExternalFileChangeCoordinator({
[projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
);
const processChangeRef = useRef(processChange);
processChangeRef.current = processChange;
useEffect(() => {
// One decoder for all three transports; the rungs only choose the channel.
const handler = (delivery?: unknown) => processChange(decodeFileChange(delivery));
const handler = (delivery?: unknown) => processChangeRef.current(decodeFileChange(delivery));
const adapter = testHotAdapter();
if (adapter) {
adapter.on("hf:file-change", handler);
@@ -461,7 +463,7 @@ export function useExternalFileChangeCoordinator({
return () => import.meta.hot?.off?.("hf:file-change", handler);
}
return sseFileChangeChannel(handler);
}, [processChange]);
}, []);
const retry = useCallback(async () => {
const current = blockedRef.current;
@@ -129,6 +129,7 @@ describe("useFileManager project ownership", () => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
{ credentials: "omit" },
);
expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
@@ -136,14 +137,18 @@ describe("useFileManager project ownership", () => {
);
expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
{ credentials: "omit" },
);
expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
expect.objectContaining({ method: "PUT", body: "A_NEW" }),
);
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html", {
credentials: "omit",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/project-b%23fragment/files/index.html?optional=1",
{ credentials: "omit" },
);
await act(async () => root.unmount());
+14 -10
View File
@@ -8,6 +8,7 @@ import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordina
import { useFileTree } from "./useFileTree";
import { useEditorSave } from "./useEditorSave";
import { useProjectFileWriter } from "./useProjectFileWriter";
import { studioApiFetch } from "../utils/studioApiFetch";
// ── Types ──
@@ -133,7 +134,7 @@ export function useFileManager({
setEditingFile({ path, content: null });
return;
}
fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
.then((r) => {
if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
return r.json();
@@ -167,9 +168,12 @@ export function useFileManager({
const requestId = ++revealRequestIdRef.current;
const controller = new AbortController();
revealAbortRef.current = controller;
fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`, {
signal: controller.signal,
})
studioApiFetch(
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`,
{
signal: controller.signal,
},
)
.then((r) => r.json())
.then((data: { content?: string; version?: string }) => {
if (requestId !== revealRequestIdRef.current) return;
@@ -200,7 +204,7 @@ export function useFileManager({
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
try {
const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
method: "POST",
body: formData,
});
@@ -246,7 +250,7 @@ export function useFileManager({
content =
'<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
}
const res = await fetch(
const res = await studioApiFetch(
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
{
method: "POST",
@@ -270,7 +274,7 @@ export function useFileManager({
async (path: string) => {
const pid = projectIdRef.current;
if (!pid) return;
const res = await fetch(
const res = await studioApiFetch(
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path + "/.gitkeep")}`,
{
method: "POST",
@@ -293,7 +297,7 @@ export function useFileManager({
async (path: string) => {
const pid = projectIdRef.current;
if (!pid) return;
const res = await fetch(
const res = await studioApiFetch(
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
{
method: "DELETE",
@@ -315,7 +319,7 @@ export function useFileManager({
async (oldPath: string, newPath: string) => {
const pid = projectIdRef.current;
if (!pid) return;
const res = await fetch(
const res = await studioApiFetch(
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(oldPath)}`,
{
method: "PATCH",
@@ -342,7 +346,7 @@ export function useFileManager({
async (path: string) => {
const pid = projectIdRef.current;
if (!pid) return;
const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
+3 -2
View File
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { FONT_EXT } from "../utils/mediaTypes";
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import { captureProjectProvenance } from "../components/feedback/projectProvenance";
import { studioApiFetch } from "../utils/studioApiFetch";
interface UseFileTreeOptions {
projectId: string | null;
@@ -38,7 +39,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
if (!projectId) return;
let cancelled = false;
setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
fetch(buildProjectApiPath(projectId))
studioApiFetch(buildProjectApiPath(projectId))
// An unresolvable project answers 404 with a JSON body, so without this
// the error path parsed cleanly and the success branch below recorded an
// empty tree — and an empty provenance snapshot — for a project that was
@@ -84,7 +85,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
refreshAbortRef.current = controller;
let data: { files?: string[]; compositions?: string[] };
try {
const res = await fetch(buildProjectApiPath(pid), { signal: controller.signal });
const res = await studioApiFetch(buildProjectApiPath(pid), { signal: controller.signal });
data = await res.json();
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
+5 -1
View File
@@ -2,6 +2,7 @@ import { useMemo, useState, useCallback, useRef, type MouseEvent } from "react";
import { useMountEffect } from "./useMountEffect";
import { liveTime, usePlayerStore } from "../player";
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
import { studioApiFetch } from "../utils/studioApiFetch";
interface UseFrameCaptureParams {
projectId: string | null;
@@ -67,7 +68,10 @@ export function useFrameCapture({
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(href, { cache: "no-store", signal: controller.signal });
const response = await studioApiFetch(href, {
cache: "no-store",
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
let msg = `Capture failed (${response.status})`;
+3 -2
View File
@@ -6,6 +6,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
import { markStudioWriteToken } from "../utils/studioFileVersion";
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
import type { RecordEditInput } from "../utils/studioFileHistory";
import { studioApiFetch } from "../utils/studioApiFetch";
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
@@ -31,7 +32,7 @@ function errorOf(value: unknown): string | null {
}
async function readVersion(projectId: string, path: string): Promise<string> {
const response = await fetch(
const response = await studioApiFetch(
buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
);
const body: unknown = await response.json().catch(() => null);
@@ -53,7 +54,7 @@ export async function requestFreezeFrame(input: {
const expectedVersion = await readVersion(input.projectId, input.path);
const transactionToken = `freeze:${crypto.randomUUID()}`;
markStudioWriteToken(transactionToken);
const response = await fetch(
const response = await studioApiFetch(
buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
{
method: "POST",
+2 -1
View File
@@ -16,6 +16,7 @@ import {
type ElementMatchSelection,
} from "../utils/studioHelpers";
import type { TimelineElement } from "../player";
import { studioApiFetch } from "../utils/studioApiFetch";
interface UseGroupCommitsParams extends DomEditCommitBaseParams {
/** Resync the SDK session after a server-side write (the wrapper/unwrap changes
@@ -132,7 +133,7 @@ async function commitStructuralMutation(
writeFile: deps.writeProjectFile,
recordEdit: deps.editHistory.recordEdit,
rewrite: async (originalContent) => {
const mutateResponse = await fetch(
const mutateResponse = await studioApiFetch(
buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
{
method: "POST",
@@ -0,0 +1,160 @@
// @vitest-environment happy-dom
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/domEditOverlayGestures";
import { usePlayerStore } from "../player/store/playerStore";
import { trackStudioEditBlocked } from "../utils/studioSaveDiagnostics";
import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
import { mountGsapAwareEditing } from "./useGsapAwareEditing.testHelpers";
vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/studioSaveDiagnostics")>()),
trackStudioEditBlocked: vi.fn(),
trackStudioSaveFailure: vi.fn(),
}));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
beforeEach(() => {
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 1, activeKeyframePct: null });
});
afterEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
Reflect.deleteProperty(window, "__timelines");
});
const positionTween = (id: string, extra: Partial<GsapAnimation> = {}) =>
({
id: `#${id}-to-0-position`,
targetSelector: `#${id}`,
propertyGroup: "position",
method: "to",
properties: { x: 100 },
position: 0,
resolvedStart: 0,
duration: 2,
ease: "none",
...extra,
}) as unknown as GsapAnimation;
/** Three members GSAP is visibly moving, one `to` tween each in the file. */
function mountGroup(animations: GsapAnimation[]) {
const elements = ["a", "b", "c"].map((id) => {
const el = Object.assign(document.createElement("div"), { id });
document.body.append(el);
return el;
});
const live = elements.map((el) => ({
targets: () => [el],
vars: { x: 100, duration: 2 },
duration: () => 2,
startTime: () => 0,
}));
const timelines = { root: { getChildren: () => live, duration: () => 2 } };
Reflect.set(window, "__timelines", timelines);
const iframe = {
contentWindow: { gsap: { getProperty: () => 0, set: vi.fn() }, __timelines: timelines },
contentDocument: document,
} as unknown as HTMLIFrameElement;
const commitMutation = Object.assign(vi.fn().mockResolvedValue(undefined), {
batch: vi.fn().mockResolvedValue(undefined),
});
const showToast = vi.fn();
const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
const { editing, root } = mountGsapAwareEditing({
gsapCommitMutation: commitMutation,
activeCompPath: "index.html",
previewIframeRef: { current: iframe },
showToast,
makeFetchFallback: () => async () => animations,
stageElementPositionOffset,
});
const groupCommit = (updates: DomEditGroupPathOffsetCommit[]) =>
editing().handleGsapAwareGroupPathOffsetCommit(updates);
const updates = elements.map((element) => ({
selection: {
element,
id: element.id,
selector: `#${element.id}`,
} as unknown as DomEditSelection,
next: { x: 30, y: 0 },
}));
const written = () => [
...commitMutation.mock.calls.map((call) => call[1]),
...commitMutation.batch.mock.calls.flatMap(([calls]) =>
calls.map((c: { mutation: unknown }) => c.mutation),
),
];
return { elements, iframe, updates, groupCommit, commitMutation, written, showToast, root };
}
describe("a group drag plans every member before its first write", () => {
it("refuses the whole group when one member's tween cannot take a keyframe", async () => {
const h = mountGroup([
positionTween("a"),
positionTween("b", { ease: undefined }),
positionTween("c"),
]);
const styles = h.elements.map((el) => el.getAttribute("style"));
await expect(h.groupCommit(h.updates)).rejects.toMatchObject({
name: "GsapEditBlockedError",
reason: "keyframes-uneditable",
detail: "unknown-ease",
});
expect(h.written()).toEqual([]);
expect(h.commitMutation.batch).not.toHaveBeenCalled();
const gsap = (h.iframe.contentWindow as unknown as { gsap: { set: unknown } }).gsap;
expect(gsap.set).not.toHaveBeenCalled();
expect(h.elements.map((el) => el.getAttribute("style"))).toEqual(styles);
expect(h.showToast).toHaveBeenCalledTimes(1);
expect(h.showToast).toHaveBeenCalledWith(GSAP_EDIT_BLOCK_COPY["keyframes-uneditable"], "error");
expect(trackStudioEditBlocked).toHaveBeenCalledTimes(1);
expect(trackStudioEditBlocked).toHaveBeenCalledWith(expect.objectContaining({ targetId: "b" }));
act(() => h.root.unmount());
});
it("refuses members animated in two files, since the batch writes one file", async () => {
const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
const updates = h.updates.map((u, i) => ({
...u,
selection: { ...u.selection, sourceFile: i === 2 ? "scenes/intro.html" : "index.html" },
}));
await expect(h.groupCommit(updates)).rejects.toMatchObject({ reason: "mixed-files" });
expect(h.written()).toEqual([]);
expect(trackStudioEditBlocked).toHaveBeenCalledWith(expect.objectContaining({ targetId: "c" }));
act(() => h.root.unmount());
});
it("writes a group whose members name the open file or leave it implied", async () => {
const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
const updates = h.updates.map((u, i) => ({
...u,
selection: { ...u.selection, sourceFile: i === 0 ? "" : "index.html" },
}));
await h.groupCommit(updates);
expect(h.written()).toHaveLength(3);
act(() => h.root.unmount());
});
it("writes every member when each plan holds", async () => {
const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
await h.groupCommit(h.updates);
const ids = h.written().map((m) => (m as { animationId?: string }).animationId);
expect(ids).toEqual(["#a-to-0-position", "#b-to-0-position", "#c-to-0-position"]);
expect(h.showToast).not.toHaveBeenCalled();
act(() => h.root.unmount());
});
});
@@ -119,18 +119,21 @@ function mountResizeHandler(
type AwareEditingParams = Parameters<typeof useGsapAwareEditing>[0];
function mountGroupHandler({
gsapCommitMutation,
makeFetchFallback,
gsapCommitMutation = vi.fn().mockResolvedValue(undefined),
makeFetchFallback = () => vi.fn().mockResolvedValue([]),
trackGsapInteractionFailure = vi.fn(),
stageElementPositionOffset = vi.fn(),
handleDomRotationCommit = vi.fn(),
}: Pick<AwareEditingParams, "gsapCommitMutation" | "makeFetchFallback"> &
Partial<
Pick<
AwareEditingParams,
"trackGsapInteractionFailure" | "stageElementPositionOffset" | "handleDomRotationCommit"
>
>) {
}: Partial<
Pick<
AwareEditingParams,
| "gsapCommitMutation"
| "makeFetchFallback"
| "trackGsapInteractionFailure"
| "stageElementPositionOffset"
| "handleDomRotationCommit"
>
> = {}) {
let groupCommit!: (
updates: DomEditGroupPathOffsetCommit[],
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
@@ -216,8 +219,6 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
const save = vi.fn().mockResolvedValue(undefined);
const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
const { pathOffsetCommit, groupCommit, root } = mountGroupHandler({
gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
stageElementPositionOffset,
});
const box = {
@@ -236,11 +237,7 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => {
it("writes no member when one CSS-route member has been folded since the press", async () => {
const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
const { groupCommit, root } = mountGroupHandler({
gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
stageElementPositionOffset,
});
const { groupCommit, root } = mountGroupHandler({ stageElementPositionOffset });
const card = {
element: document.createElement("div"),
id: "card",
@@ -268,7 +265,6 @@ describe("useGsapAwareEditing rotation routing", () => {
const gsapCommitMutation = vi.fn();
const h = mountGroupHandler({
gsapCommitMutation,
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
handleDomRotationCommit,
});
const box = { element, id: "box", selector: "#box" } as unknown as DomEditSelection;
@@ -332,7 +328,6 @@ describe("useGsapAwareEditing shared-tween moves", () => {
const commitMutation = vi.fn();
const { pathOffsetCommit, root } = mountGroupHandler({
gsapCommitMutation: commitMutation,
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
stageElementPositionOffset,
});
const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
@@ -351,11 +346,7 @@ describe("useGsapAwareEditing shared-tween moves", () => {
});
const save = vi.fn().mockResolvedValue(undefined);
const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
const { groupCommit, root } = mountGroupHandler({
gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
stageElementPositionOffset,
});
const { groupCommit, root } = mountGroupHandler({ stageElementPositionOffset });
const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
const box = { element: gsapPositioned("div"), id: "box", selector: "#box" };
const plain = { element: document.createElement("div"), id: "plain", selector: "#plain" };
@@ -406,6 +397,7 @@ describe("useGsapAwareEditing anchored resize", () => {
null,
expect.any(Function),
expect.any(Function),
undefined,
);
act(() => h.root.unmount());
});
@@ -658,7 +650,6 @@ describe("useGsapAwareEditing anchored resize", () => {
);
const { groupCommit, root } = mountGroupHandler({
gsapCommitMutation: commitMutation,
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
});
const updates = [
{
@@ -759,11 +750,7 @@ describe("useGsapAwareEditing anchored resize", () => {
mocks.drag.mockImplementation(async (selection) => {
throw selection.id === "a" ? failures[0] : failures[1];
});
const { groupCommit, root } = mountGroupHandler({
gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
trackGsapInteractionFailure,
});
const { groupCommit, root } = mountGroupHandler({ trackGsapInteractionFailure });
const updates = [
{
selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
@@ -782,6 +769,7 @@ describe("useGsapAwareEditing anchored resize", () => {
updates[0]?.selection,
"drag",
"Move animated layer (group)",
true,
);
mocks.drag.mockReset();
if (priorDragImplementation) mocks.drag.mockImplementation(priorDragImplementation);

Some files were not shown because too many files have changed in this diff Show More