mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
Merge pull request #1826 from heygen-com/fix/studio-recent-issues
fix(studio): resolve timeline keyframe, click-selection, and nested-video sync regressions
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { shouldWatchProjectFile } from "./fileWatcher.js";
|
||||
const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void };
|
||||
mockWatcher.close = vi.fn();
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
watch: vi.fn(() => mockWatcher),
|
||||
}));
|
||||
|
||||
const { shouldWatchProjectFile, createProjectWatcher } = await import("./fileWatcher.js");
|
||||
|
||||
describe("shouldWatchProjectFile", () => {
|
||||
it("watches files that can affect the project signature", () => {
|
||||
@@ -17,3 +25,15 @@ describe("shouldWatchProjectFile", () => {
|
||||
expect(shouldWatchProjectFile(".hyperframes/cache.json")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProjectWatcher", () => {
|
||||
// Regression: fs.watch can fail asynchronously (e.g. EMFILE from exhausted
|
||||
// OS watch handles) via an 'error' event, not a thrown exception. An
|
||||
// EventEmitter 'error' with no listener crashes the whole process — this
|
||||
// must degrade gracefully instead, per the sibling synchronous-failure path.
|
||||
it("does not crash the process when the underlying watcher emits 'error'", () => {
|
||||
createProjectWatcher("/fake/project/dir");
|
||||
expect(() => mockWatcher.emit("error", new Error("EMFILE"))).not.toThrow();
|
||||
expect(mockWatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,15 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
});
|
||||
// fs.watch can fail asynchronously too (e.g. EMFILE from exhausted OS watch
|
||||
// handles) — that surfaces as an 'error' event, not a thrown exception. An
|
||||
// EventEmitter 'error' with no listener crashes the whole process, so this
|
||||
// listener is required for the same "degrade gracefully" the catch below
|
||||
// already promises for the synchronous failure mode.
|
||||
watcher.on("error", () => {
|
||||
watcher?.close();
|
||||
watcher = null;
|
||||
});
|
||||
} catch {
|
||||
// fs.watch may fail on some platforms — degrade gracefully (no auto-refresh)
|
||||
}
|
||||
|
||||
@@ -578,6 +578,56 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(video.currentTime).toBe(9);
|
||||
});
|
||||
|
||||
// Regression (#1838): a video authoring its OWN data-start (the normal case
|
||||
// for a timed clip the studio positions on a track) took a fast literal-
|
||||
// value path that skipped adding the host composition's start offset —
|
||||
// unlike the no-own-data-start case above, which already went through
|
||||
// resolveStartForElement and got the offset for free. The video played
|
||||
// from the ROOT timeline's time instead of holding until its parent scene
|
||||
// began, desyncing from the correctly-offset GSAP overlay in the same scene.
|
||||
it("offsets a nested video's own data-start by its host composition's start", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const child = document.createElement("div");
|
||||
child.setAttribute("data-composition-id", "scene-2");
|
||||
child.setAttribute("data-start", "20");
|
||||
child.setAttribute("data-duration", "16");
|
||||
root.appendChild(child);
|
||||
|
||||
const video = document.createElement("video");
|
||||
// Authored relative to scene-2's own local timeline, not the root's.
|
||||
video.setAttribute("data-start", "0");
|
||||
video.setAttribute("data-duration", "16");
|
||||
child.appendChild(video);
|
||||
Object.defineProperty(video, "duration", { value: 20, writable: true, configurable: true });
|
||||
Object.defineProperty(video, "paused", { value: true, writable: true, configurable: true });
|
||||
Object.defineProperty(video, "readyState", { value: 4, writable: true, configurable: true });
|
||||
Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true });
|
||||
video.load = () => {};
|
||||
video.play = () => Promise.resolve();
|
||||
|
||||
window.__timelines = {
|
||||
main: createMockTimeline(40),
|
||||
"scene-2": createMockTimeline(16),
|
||||
};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
const player = window.__player;
|
||||
expect(player).toBeDefined();
|
||||
|
||||
// Root t=25 is 5s into scene-2 (which starts at root t=20) — the video
|
||||
// must be 5s into its own local playback, not 25s (root time).
|
||||
player?.seek(25);
|
||||
|
||||
expect(video.currentTime).toBe(5);
|
||||
});
|
||||
|
||||
it("updates visibility for timed elements inside nested compositions", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -496,7 +496,16 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
const resolveMediaStartSeconds = (element: Element, fallback = 0): number => {
|
||||
if (!element.hasAttribute("data-hf-auto-start") && element.hasAttribute("data-start")) {
|
||||
return Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0);
|
||||
// `data-start` is authored relative to the media element's OWN sub-
|
||||
// composition, not the root timeline — `fallback` carries the host
|
||||
// composition's resolved absolute start (see syncMediaForCurrentState's
|
||||
// inheritedStart), so it must be added, not discarded. Skipping it made
|
||||
// a nested video play from root t=0 instead of holding until its
|
||||
// parent scene began (issue #1838) — resolveStartForElement's own
|
||||
// absolute-expression branch already adds this same host offset, this
|
||||
// fast literal-value path just didn't.
|
||||
const own = Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0);
|
||||
return own + fallback;
|
||||
}
|
||||
return resolveStartForElement(element, fallback);
|
||||
};
|
||||
|
||||
@@ -1656,6 +1656,26 @@ describe("keyframe mutations", () => {
|
||||
expect(kfs[1].properties.x).toBe(999);
|
||||
});
|
||||
|
||||
it("addKeyframeToScript — preserves exactly one ease when updating an eased keyframe", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { "0%": { scale: 1 }, "60%": { scale: 1.18, ease: "power2.out" }, "100%": { scale: 1 } },
|
||||
duration: 2
|
||||
}, 0);
|
||||
`;
|
||||
const id = getAnimId(script);
|
||||
const updated = addKeyframeToScript(script, id, 60, { scale: 1.18, x: 10, y: 20 });
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const keyframe = reparsed.animations[0].keyframes!.keyframes.find(
|
||||
(kf) => kf.percentage === 60,
|
||||
)!;
|
||||
|
||||
expect((updated.match(/ease:\s*"power2\.out"/g) ?? []).length).toBe(1);
|
||||
expect(keyframe.ease).toBe("power2.out");
|
||||
expect(keyframe.properties).toMatchObject({ scale: 1.18, x: 10, y: 20 });
|
||||
});
|
||||
|
||||
// ── backfillDefaults: editing one keyframe must not move the others ──────
|
||||
// UX invariant (CapCut/AE): keyframes are independent. Introducing a property
|
||||
// to one keyframe (e.g. `y` on an x-only tween) must backfill the other
|
||||
|
||||
@@ -1994,8 +1994,11 @@ function buildKeyframeValueNode(
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): AstNode {
|
||||
const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
if (ease) entries.push(`ease: ${JSON.stringify(ease)}`);
|
||||
const effectiveEase = ease ?? (typeof properties.ease === "string" ? properties.ease : undefined);
|
||||
const entries = Object.entries(properties)
|
||||
.filter(([k]) => k !== "ease")
|
||||
.map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
if (effectiveEase) entries.push(`ease: ${JSON.stringify(effectiveEase)}`);
|
||||
return parseExpr(`{ ${entries.join(", ")} }`);
|
||||
}
|
||||
|
||||
@@ -2335,7 +2338,11 @@ export function moveKeyframeInScript(
|
||||
): string {
|
||||
const loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
// Array-form keyframes can't host an arbitrary destination percentage —
|
||||
// normalize to object form in place first (mirrors addKeyframeToScript).
|
||||
const kfNode =
|
||||
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
||||
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const match = findKeyframePropByPct(kfNode, fromPercentage);
|
||||
@@ -2392,7 +2399,11 @@ export function resizeKeyframedTweenInScript(
|
||||
): string {
|
||||
const loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
// Array-form keyframes can't host an arbitrary re-keyed percentage —
|
||||
// normalize to object form in place first (mirrors addKeyframeToScript).
|
||||
const kfNode =
|
||||
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
||||
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const seen = new Set<AstNode>();
|
||||
@@ -2592,7 +2603,13 @@ export function convertToKeyframesInScript(
|
||||
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
|
||||
let loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
// Array-form keyframes have no percentage-keyed props for
|
||||
// filterPercentageProps to read — normalize to object form first (mirrors
|
||||
// addKeyframeToScript/moveKeyframeInScript), otherwise this silently no-ops
|
||||
// on every array-form tween.
|
||||
const kfNode =
|
||||
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
||||
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const kfEntries = filterPercentageProps(kfNode)
|
||||
|
||||
@@ -1216,6 +1216,34 @@ describe("parity: moveKeyframeInScript (recast vs acorn)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: array-form `keyframes: [...]` has no explicit percentages, so
|
||||
// locateWithKeyframes/findKeyframesObjectNode (which only match the object
|
||||
// form) resolved to nothing and the move silently no-op'd — Studio's "Move to
|
||||
// Playhead" and drag-to-retime did nothing on any array-authored tween. Both
|
||||
// writers now normalize array → object form first (mirrors addKeyframeToScript).
|
||||
describe("moveKeyframeInScript: array-form keyframes (recast + acorn parity)", () => {
|
||||
for (const [label, move] of [
|
||||
["acorn", moveKeyframeAcorn],
|
||||
["recast", moveKeyframeRecast],
|
||||
] as const) {
|
||||
it(`${label}: normalizes the array then retimes the moved keyframe`, () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
const out = move(KF_ADD_ARRAY_SCRIPT, id, 50, 75);
|
||||
expect(out).not.toBe(KF_ADD_ARRAY_SCRIPT);
|
||||
const kfs = shapeOf(out).keyframes?.keyframes ?? [];
|
||||
expect(kfs.map((k) => k.percentage)).toEqual([0, 75, 100]);
|
||||
expect(kfs.find((k) => k.percentage === 75)!.properties).toEqual({ x: 50, y: 80 });
|
||||
});
|
||||
}
|
||||
|
||||
it("parity: both writers reparse to the same model", () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
expect(modelOf(moveKeyframeAcorn(KF_ADD_ARRAY_SCRIPT, id, 50, 75))).toEqual(
|
||||
modelOf(moveKeyframeRecast(KF_ADD_ARRAY_SCRIPT, id, 50, 75)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resizeKeyframedTweenInScript (boundary drag: re-key + grow window) ────────
|
||||
// Boundary drag-to-retime grows/shifts the tween window and RE-KEYS keyframes in
|
||||
// place. Unlike replace-with-keyframes (array rebuild), it must preserve author
|
||||
@@ -1272,6 +1300,67 @@ describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: same array-form gap as moveKeyframeInScript above — boundary
|
||||
// drag-to-retime re-keys existing keyframes to arbitrary percentages, which an
|
||||
// array can't host. Both writers now normalize array → object form first.
|
||||
const RESIZE_ARRAY_REMAP = [
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 50, to: 25 },
|
||||
{ from: 100, to: 100 },
|
||||
];
|
||||
|
||||
describe("resizeKeyframedTweenInScript: array-form keyframes (recast + acorn parity)", () => {
|
||||
for (const [label, resize] of [
|
||||
["acorn", resizeKeyframedTweenAcorn],
|
||||
["recast", resizeKeyframedTweenRecast],
|
||||
] as const) {
|
||||
it(`${label}: normalizes the array then re-keys to the remapped percentages`, () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
const out = resize(KF_ADD_ARRAY_SCRIPT, id, 0.2, 2, RESIZE_ARRAY_REMAP);
|
||||
expect(out).not.toBe(KF_ADD_ARRAY_SCRIPT);
|
||||
const kfs = shapeOf(out).keyframes?.keyframes ?? [];
|
||||
expect(kfs.map((k) => k.percentage)).toEqual([0, 25, 100]);
|
||||
expect(kfs.find((k) => k.percentage === 25)!.properties).toEqual({ x: 50, y: 80 });
|
||||
});
|
||||
}
|
||||
|
||||
it("parity: both writers reparse to the same model", () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
expect(
|
||||
modelOf(resizeKeyframedTweenAcorn(KF_ADD_ARRAY_SCRIPT, id, 0.2, 2, RESIZE_ARRAY_REMAP)),
|
||||
).toEqual(
|
||||
modelOf(resizeKeyframedTweenRecast(KF_ADD_ARRAY_SCRIPT, id, 0.2, 2, RESIZE_ARRAY_REMAP)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllKeyframesFromScript: array-form keyframes (recast + acorn parity)", () => {
|
||||
// Regression: the recast writer required an object-form `keyframes` node
|
||||
// before doing anything, so array-form tweens silently no-op'd — the studio
|
||||
// clears its keyframe cache optimistically on delete-all, so the diamonds
|
||||
// vanished from the UI while the script kept every keyframe untouched.
|
||||
for (const [label, removeAll] of [
|
||||
["acorn", removeAllAcorn],
|
||||
["recast", removeAllRecast],
|
||||
] as const) {
|
||||
it(`${label}: normalizes the array then collapses to the last keyframe`, () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
const out = removeAll(KF_ADD_ARRAY_SCRIPT, id);
|
||||
expect(out).not.toBe(KF_ADD_ARRAY_SCRIPT);
|
||||
const shape = shapeOf(out);
|
||||
expect(shape.keyframes).toBeUndefined();
|
||||
expect(shape.properties).toEqual({ x: 100, y: 0 });
|
||||
});
|
||||
}
|
||||
|
||||
it("parity: both writers reparse to the same model", () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
expect(modelOf(removeAllAcorn(KF_ADD_ARRAY_SCRIPT, id))).toEqual(
|
||||
modelOf(removeAllRecast(KF_ADD_ARRAY_SCRIPT, id)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addAnimationWithKeyframesToScript parity (recast vs acorn) ───────────────
|
||||
// WS-3.C add path: both writers insert a new keyframed tl.to() call. The
|
||||
// inserted statement's authored model (selector, keyframes, duration, ease,
|
||||
|
||||
@@ -1209,17 +1209,20 @@ export function moveKeyframeInScript(
|
||||
fromPercentage: number,
|
||||
toPercentage: number,
|
||||
): string {
|
||||
const located = locateWithKeyframes(script, animationId);
|
||||
// ensureKeyframesNode (not locateWithKeyframes) so array-form `keyframes: [...]`
|
||||
// tweens normalize to percentage-object form first — locateWithKeyframes alone
|
||||
// bails on ArrayExpression, silently no-op'ing every move on an array-form tween.
|
||||
const located = ensureKeyframesNode(script, animationId);
|
||||
if (!located) return script;
|
||||
const { kfNode } = located;
|
||||
const { script: src, kfNode } = located;
|
||||
|
||||
const match = findKfPropByPct(kfNode, fromPercentage);
|
||||
if (!match) return script;
|
||||
if (!match) return src;
|
||||
// No-op ONLY for a negligible move (matches the drag's NOOP_EPSILON). The old
|
||||
// `collision.prop === match.prop` guard dropped EVERY sub-PCT_TOLERANCE (2%)
|
||||
// retime, because findKfPropByPct resolves the destination back onto the
|
||||
// from-keyframe — so a deliberate 1% drag committed nothing.
|
||||
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return script;
|
||||
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return src;
|
||||
// A destination keyframe is only a real collision (overwrite) when it's a
|
||||
// DIFFERENT keyframe; resolving back onto the from-keyframe is not.
|
||||
const dest = findKfPropByPct(kfNode, toPercentage);
|
||||
@@ -1234,15 +1237,15 @@ export function moveKeyframeInScript(
|
||||
if (collision && prop === collision.prop) continue;
|
||||
const pct = percentageFromKey(propKeyName(prop) ?? "");
|
||||
if (Number.isNaN(pct)) continue;
|
||||
entries.push({ pct, record: valueNodeToRecord(prop.value, script) });
|
||||
entries.push({ pct, record: valueNodeToRecord(prop.value, src) });
|
||||
}
|
||||
entries.push({ pct: toPercentage, record: valueNodeToRecord(match.prop.value, script) });
|
||||
entries.push({ pct: toPercentage, record: valueNodeToRecord(match.prop.value, src) });
|
||||
entries.sort((a, b) => a.pct - b.pct);
|
||||
|
||||
const body = entries
|
||||
.map((e) => `${JSON.stringify(`${e.pct}%`)}: ${recordToCode(e.record)}`)
|
||||
.join(", ");
|
||||
const ms = new MagicString(script);
|
||||
const ms = new MagicString(src);
|
||||
ms.overwrite(kfNode.start, kfNode.end, `{ ${body} }`);
|
||||
return ms.toString();
|
||||
}
|
||||
@@ -1266,9 +1269,11 @@ export function resizeKeyframedTweenInScript(
|
||||
newDuration: number,
|
||||
pctRemap: ReadonlyArray<{ from: number; to: number }>,
|
||||
): string {
|
||||
const located = locateWithKeyframes(script, animationId);
|
||||
// ensureKeyframesNode (not locateWithKeyframes) so array-form `keyframes: [...]`
|
||||
// tweens normalize to percentage-object form first — see moveKeyframeInScript.
|
||||
const located = ensureKeyframesNode(script, animationId);
|
||||
if (!located) return script;
|
||||
const { target, kfNode } = located;
|
||||
const { script: src, target, kfNode } = located;
|
||||
|
||||
// Resolve every re-key against the ORIGINAL AST first (offsets stay stable),
|
||||
// then splice — distinct key nodes, so the overwrites never overlap. A Set
|
||||
@@ -1282,7 +1287,7 @@ export function resizeKeyframedTweenInScript(
|
||||
edits.push({ keyNode: match.prop.key, to });
|
||||
}
|
||||
|
||||
const ms = new MagicString(script);
|
||||
const ms = new MagicString(src);
|
||||
for (const { keyNode, to } of edits) {
|
||||
ms.overwrite(keyNode.start, keyNode.end, JSON.stringify(`${to}%`));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { TimelineToolbar } from "./TimelineToolbar";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
||||
});
|
||||
|
||||
function renderToolbar() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(<TimelineToolbar toggleTimelineVisibility={vi.fn()} />);
|
||||
});
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
// Regression (#1808): the auto-keyframe toggle is a GLOBAL setting (unlike the
|
||||
// diamond "Add keyframe" button, which needs a selection to mean anything), so
|
||||
// it must stay visible and usable with nothing selected — it must not be
|
||||
// gated behind `domEditSession`/`onToggleKeyframe`.
|
||||
describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
|
||||
it("renders enabled (pressed) by default with no selection", () => {
|
||||
const { host, root } = renderToolbar();
|
||||
const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
|
||||
expect(btn).not.toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("flips autoKeyframeEnabled in the store when clicked", () => {
|
||||
const { host, root } = renderToolbar();
|
||||
const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')!;
|
||||
|
||||
act(() => {
|
||||
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
|
||||
expect(host.querySelector('button[aria-pressed="false"]')).not.toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,8 @@ export function TimelineToolbar({
|
||||
}: TimelineToolbarProps) {
|
||||
const activeTool = usePlayerStore((s) => s.activeTool);
|
||||
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
||||
const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
|
||||
const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
|
||||
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
|
||||
@@ -137,44 +139,77 @@ export function TimelineToolbar({
|
||||
</div>
|
||||
)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
|
||||
<>
|
||||
<Tooltip
|
||||
label={
|
||||
<Tooltip
|
||||
label={
|
||||
keyframeState === "active"
|
||||
? "Remove keyframe at playhead (K)"
|
||||
: keyframeState === "inactive"
|
||||
? keyframeWillExtend
|
||||
? "Add keyframe at playhead, extends animation (K)"
|
||||
: "Add keyframe at playhead (K)"
|
||||
: "Add keyframe (K)"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleKeyframe}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
keyframeState === "active"
|
||||
? "Remove keyframe at playhead (K)"
|
||||
? "text-studio-accent"
|
||||
: keyframeState === "inactive"
|
||||
? keyframeWillExtend
|
||||
? "Add keyframe at playhead — extends animation (K)"
|
||||
: "Add keyframe at playhead (K)"
|
||||
: "Add keyframe (K)"
|
||||
}
|
||||
? "text-neutral-400 hover:text-studio-accent"
|
||||
: "text-neutral-600 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleKeyframe}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
keyframeState === "active"
|
||||
? "text-studio-accent"
|
||||
: keyframeState === "inactive"
|
||||
? "text-neutral-400 hover:text-studio-accent"
|
||||
: "text-neutral-600 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
||||
{keyframeState === "active" ? (
|
||||
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
||||
) : (
|
||||
<path
|
||||
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
||||
{keyframeState === "active" ? (
|
||||
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
||||
) : (
|
||||
<path
|
||||
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && (
|
||||
<Tooltip
|
||||
label={
|
||||
autoKeyframeEnabled
|
||||
? "Auto-record manual edits as keyframes (click to turn off)"
|
||||
: "Manual edits will not be recorded as keyframes (click to turn on)"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
|
||||
aria-pressed={autoKeyframeEnabled}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
autoKeyframeEnabled
|
||||
? "text-red-400 hover:text-red-300"
|
||||
: "text-neutral-600 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 10 10" fill="none">
|
||||
{/* Same diamond outline as the Add-keyframe icon, with a
|
||||
record-style dot inside: filled = auto-recording,
|
||||
hollow = manual edits won't be keyframed. */}
|
||||
<path d="M5 0.7L9.3 5L5 9.3L0.7 5Z" stroke="currentColor" strokeWidth="1" />
|
||||
<circle
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="1.8"
|
||||
fill={autoKeyframeEnabled ? "currentColor" : "none"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onSplitElement &&
|
||||
(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "./domEditing";
|
||||
import { useDomEditContext } from "../../contexts/DomEditContext";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
|
||||
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
|
||||
import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
|
||||
import { editableAnimationId, selectorFor } from "./motionPathSelection";
|
||||
import { ACCENT, MotionPathNode } from "./MotionPathNode";
|
||||
@@ -334,13 +335,35 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
// high zoom) would commit an identical value — a no-op undo entry. Skip the
|
||||
// commit, but don't treat it as a click either (the user did drag).
|
||||
if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
|
||||
void commitNode(d.ref, x, y, animId, commitMutation);
|
||||
// With auto-keyframe off (#1808), dragging a keyframe's node on the motion
|
||||
// path (the common way to nudge a KEYFRAMED element's position on canvas,
|
||||
// since the element renders exactly at its current keyframe) shifts the
|
||||
// whole path instead of moving just that one keyframe.
|
||||
const anim =
|
||||
d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
|
||||
if (
|
||||
d.ref.type === "keyframe" &&
|
||||
anim &&
|
||||
selection &&
|
||||
!usePlayerStore.getState().autoKeyframeEnabled
|
||||
) {
|
||||
void commitWholePropertyOffset(
|
||||
selection,
|
||||
anim,
|
||||
{ x, y },
|
||||
d.ref.pct,
|
||||
iframeRef.current,
|
||||
{ commitMutation: (_sel, mutation, options) => commitMutation(mutation, options) },
|
||||
"Move animation path",
|
||||
);
|
||||
} else {
|
||||
void commitNode(d.ref, x, y, animId, commitMutation);
|
||||
}
|
||||
// Park the playhead on the edited keyframe's time so the element previews AT
|
||||
// that keyframe. Without it, a playhead sitting before the tween renders the
|
||||
// element's base pose — the edit (correct on the path) looks like it vanished.
|
||||
if (d.ref.type === "keyframe") {
|
||||
const anim = selectedGsapAnimations?.find((a) => a.id === animId);
|
||||
if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct);
|
||||
if (d.ref.type === "keyframe" && anim) {
|
||||
parkPlayheadOnKeyframe(anim, d.ref.pct);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAppHotkeys } from "../../hooks/useAppHotkeys";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import { SnapToolbar } from "./SnapToolbar";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
window.localStorage.clear();
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
function renderToolbar(onSnapChange = vi.fn()) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(<SnapToolbar onSnapChange={onSnapChange} />);
|
||||
});
|
||||
return { root, onSnapChange };
|
||||
}
|
||||
|
||||
function AppHotkeyHarness() {
|
||||
const domEditSelectionRef = useRef<DomEditSelection | null>(null);
|
||||
const clearDomSelectionRef = useRef<() => void>(() => undefined);
|
||||
const domEditSaveTimestampRef = useRef(0);
|
||||
const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
|
||||
|
||||
useAppHotkeys({
|
||||
toggleTimelineVisibility: vi.fn(),
|
||||
handleTimelineElementDelete: vi.fn(),
|
||||
handleTimelineElementSplit: vi.fn(),
|
||||
handleDomEditElementDelete: vi.fn(),
|
||||
domEditSelectionRef,
|
||||
clearDomSelectionRef,
|
||||
editHistory: {
|
||||
undo: vi.fn(async () => ({ ok: false })),
|
||||
redo: vi.fn(async () => ({ ok: false })),
|
||||
state: { undo: [], redo: [] },
|
||||
},
|
||||
readOptionalProjectFile: vi.fn(async () => ""),
|
||||
readProjectFile: vi.fn(async () => ""),
|
||||
writeProjectFile: vi.fn(async () => undefined),
|
||||
domEditSaveTimestampRef,
|
||||
showToast: vi.fn(),
|
||||
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
||||
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
||||
leftSidebarRef,
|
||||
handleCopy: vi.fn(() => false),
|
||||
handlePaste: vi.fn(async () => undefined),
|
||||
handleCut: vi.fn(async () => false),
|
||||
onResetKeyframes: vi.fn(() => false),
|
||||
onDeleteSelectedKeyframes: vi.fn(),
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<AppHotkeyHarness />
|
||||
<SnapToolbar onSnapChange={onSnapChange} />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
return { root, onSnapChange };
|
||||
}
|
||||
|
||||
describe("SnapToolbar keyboard shortcuts", () => {
|
||||
it("toggles snap on an unclaimed S keypress", () => {
|
||||
const { root, onSnapChange } = renderToolbar();
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "s", bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSnapChange).toHaveBeenCalledWith(expect.objectContaining({ snapEnabled: false }));
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not toggle snap when another handler already prevented S", () => {
|
||||
const { root, onSnapChange } = renderToolbar();
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "s",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
event.preventDefault();
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(onSnapChange).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not toggle snap when the app split shortcut claims S without a selected clip", () => {
|
||||
const { root, onSnapChange } = renderToolbarWithAppHotkeys();
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "s", bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSnapChange).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
||||
useEffect(() => {
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
|
||||
if (t instanceof HTMLElement && t.isContentEditable) return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
|
||||
/**
|
||||
* Regression: `selectedGsapAnimations` (and the fetch fallback) is an async
|
||||
@@ -178,3 +179,68 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
||||
expect(staleLogged).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression (#1808): with the global auto-keyframe toggle off, dragging an
|
||||
// element that already has a keyframed position tween must shift the whole
|
||||
// tween (a "replace-with-keyframes" carrying every original percentage) —
|
||||
// the same path Alt-drag already takes — instead of inserting a keyframe at
|
||||
// the playhead.
|
||||
describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
|
||||
afterEach(() => {
|
||||
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
||||
});
|
||||
|
||||
const keyframedPositionAnim = {
|
||||
id: "#puck-b-to-position",
|
||||
targetSelector: "#puck-b",
|
||||
propertyGroup: "position",
|
||||
method: "to",
|
||||
properties: {},
|
||||
position: 0,
|
||||
resolvedStart: 0,
|
||||
duration: 2,
|
||||
keyframes: {
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
},
|
||||
} as unknown as GsapAnimation;
|
||||
|
||||
it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
|
||||
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 2 }); // playhead at 100%
|
||||
const commitMutation = vi.fn();
|
||||
const iframe = fakeIframe("puck-b", []);
|
||||
|
||||
const handled = await tryGsapDragIntercept(
|
||||
selection,
|
||||
{ x: -50, y: 0 },
|
||||
[keyframedPositionAnim],
|
||||
iframe,
|
||||
commitMutation,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const types = commitMutation.mock.calls.map(([, m]) => m.type);
|
||||
expect(types).toContain("replace-with-keyframes");
|
||||
expect(types).not.toContain("add-keyframe");
|
||||
});
|
||||
|
||||
it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
|
||||
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 2 });
|
||||
const commitMutation = vi.fn();
|
||||
const iframe = fakeIframe("puck-b", []);
|
||||
|
||||
const handled = await tryGsapDragIntercept(
|
||||
selection,
|
||||
{ x: -50, y: 0 },
|
||||
[keyframedPositionAnim],
|
||||
iframe,
|
||||
commitMutation,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const types = commitMutation.mock.calls.map(([, m]) => m.type);
|
||||
expect(types).not.toContain("replace-with-keyframes");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
findSizeSetAnimation,
|
||||
materializeIfDynamic,
|
||||
} from "./gsapDragCommit";
|
||||
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
||||
import { selectorFromSelection } from "./gsapShared";
|
||||
@@ -225,7 +226,12 @@ export async function tryGsapDragIntercept(
|
||||
}
|
||||
|
||||
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
|
||||
if (options?.altKey) {
|
||||
// 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) {
|
||||
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
||||
} else {
|
||||
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
||||
@@ -332,6 +338,24 @@ export async function tryGsapResizeIntercept(
|
||||
height: Math.round(size.height),
|
||||
};
|
||||
}
|
||||
|
||||
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
|
||||
// tween for this resize group, so nudge it as a whole rather than adding a
|
||||
// keyframe at the playhead.
|
||||
if (!usePlayerStore.getState().autoKeyframeEnabled) {
|
||||
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
||||
await commitWholePropertyOffset(
|
||||
selection,
|
||||
anim,
|
||||
resizeProps,
|
||||
pct,
|
||||
iframe,
|
||||
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
||||
"Resize animation",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(anim);
|
||||
const td = resolveTweenDuration(anim);
|
||||
@@ -490,6 +514,22 @@ export async function tryGsapRotationIntercept(
|
||||
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
|
||||
// With auto-keyframe off (#1808), a rotation tween already exists for this
|
||||
// element (checked above) so nudge it as a whole rather than adding a
|
||||
// keyframe at the playhead.
|
||||
if (!usePlayerStore.getState().autoKeyframeEnabled) {
|
||||
await commitWholePropertyOffset(
|
||||
selection,
|
||||
anim,
|
||||
{ rotation: newRotation },
|
||||
pct,
|
||||
iframe,
|
||||
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
||||
"Rotate animation",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
||||
if (newId) anim = { ...anim, id: newId };
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
|
||||
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
|
||||
return {
|
||||
id: "a1",
|
||||
targetSelector: "#title",
|
||||
method: "to",
|
||||
position: 0,
|
||||
properties: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("synthesizeFlatTweenKeyframes", () => {
|
||||
it("returns null for a set() static hold", () => {
|
||||
expect(synthesizeFlatTweenKeyframes(anim({ method: "set", properties: { x: 5 } }))).toBeNull();
|
||||
});
|
||||
|
||||
// Regression: removeAllKeyframesFromScript collapses a keyframed tween to
|
||||
// `tl.to(..., { duration: 0, immediateRender: true })` — a static hold with
|
||||
// the same "not an animation" semantics as set(), but a different method
|
||||
// string. Before this fix, only position-only (x/y) collapses were treated
|
||||
// as holds elsewhere; a collapse to scale/opacity (or anything else) still
|
||||
// synthesized a phantom keyframe diamond after "Delete All Keyframes".
|
||||
it("returns null for a to() collapsed to a zero-duration immediateRender hold", () => {
|
||||
const collapsed = anim({
|
||||
method: "to",
|
||||
duration: 0,
|
||||
// Both parsers encode a literal `immediateRender: true` as this raw
|
||||
// source string, not a boolean — see gsapParser.ts/gsapParserAcorn.ts.
|
||||
extras: { immediateRender: "__raw:true" },
|
||||
properties: { scale: 1, opacity: 1 },
|
||||
});
|
||||
expect(synthesizeFlatTweenKeyframes(collapsed)).toBeNull();
|
||||
});
|
||||
|
||||
it("still synthesizes keyframes for a genuine animated to() tween", () => {
|
||||
const out = synthesizeFlatTweenKeyframes(
|
||||
anim({ method: "to", duration: 1, properties: { opacity: 1 } }),
|
||||
);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 100]);
|
||||
});
|
||||
|
||||
it("still synthesizes keyframes for a duration:0 tween that isn't an immediateRender hold", () => {
|
||||
// duration:0 alone isn't enough — only paired with immediateRender does it
|
||||
// mean "this is a static hold, not an animation".
|
||||
const out = synthesizeFlatTweenKeyframes(
|
||||
anim({ method: "to", duration: 0, properties: { opacity: 1 } }),
|
||||
);
|
||||
expect(out).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -23,12 +23,18 @@ export function deduplicateKeyframes(
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
|
||||
if (anim.method === "set") {
|
||||
// A `set` is a STATIC HOLD — a value applied at one point, not an animated
|
||||
// keyframe. It must NOT synthesize a keyframe, or the timeline + panel show a
|
||||
// phantom diamond for a value that doesn't animate. This holds for a base
|
||||
// `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and aligns the AST
|
||||
// path with the runtime scan, which already skips every zero-duration set.
|
||||
// Both parsers store extras as raw source text (`__raw:${code}`) so
|
||||
// non-editable config like `stagger: {...}` survives verbatim — a literal
|
||||
// `immediateRender: true` prints as exactly this string, not a boolean.
|
||||
const hasImmediateRenderHold = anim.extras?.immediateRender === "__raw:true";
|
||||
if (anim.method === "set" || (anim.duration === 0 && hasImmediateRenderHold)) {
|
||||
// A `set` — or a `to()`/`from()` collapsed to a zero-duration
|
||||
// immediateRender hold (what removeAllKeyframesFromScript collapses a
|
||||
// keyframed tween to) — is a STATIC HOLD: a value applied at one point,
|
||||
// not an animated keyframe. It must NOT synthesize a keyframe, or the
|
||||
// timeline + panel show a phantom diamond for a value that doesn't
|
||||
// animate. This aligns the AST path with the runtime scan, which already
|
||||
// skips every zero-duration set.
|
||||
return null;
|
||||
}
|
||||
const toProps = anim.properties;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
||||
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
||||
|
||||
// Regression (#1808): with auto-keyframe recording off, a manual edit on an
|
||||
// element that already has a keyframed tween must shift every keyframe by
|
||||
// the edit's delta (preserving the animation's shape) instead of inserting
|
||||
// or updating a keyframe at the playhead.
|
||||
|
||||
const selection = (): DomEditSelection => ({ id: "box", selector: "#box" }) as DomEditSelection;
|
||||
|
||||
function recordingCallbacks(): {
|
||||
mutations: Array<Record<string, unknown>>;
|
||||
callbacks: GsapDragCommitCallbacks;
|
||||
} {
|
||||
const mutations: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
mutations,
|
||||
callbacks: {
|
||||
commitMutation: async (_sel, mutation) => {
|
||||
mutations.push(mutation);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("commitWholePropertyOffset", () => {
|
||||
it("shifts every keyframe of a keyframed tween by the delta at the nearest keyframe", async () => {
|
||||
const anim = {
|
||||
id: "#box-rotate",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
resolvedStart: 0,
|
||||
duration: 2,
|
||||
keyframes: {
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { rotation: 10 } },
|
||||
{ percentage: 50, properties: { rotation: 20 } },
|
||||
{ percentage: 100, properties: { rotation: 40 } },
|
||||
],
|
||||
},
|
||||
} as unknown as GsapAnimation;
|
||||
|
||||
const { mutations, callbacks } = recordingCallbacks();
|
||||
// currentPct=48 lands nearest the 50% keyframe (rotation 20) — dragging to
|
||||
// 30 is a +10 delta, applied to every keyframe.
|
||||
await commitWholePropertyOffset(
|
||||
selection(),
|
||||
anim,
|
||||
{ rotation: 30 },
|
||||
48,
|
||||
null,
|
||||
callbacks,
|
||||
"Rotate",
|
||||
);
|
||||
|
||||
expect(mutations).toHaveLength(1);
|
||||
const mutation = mutations[0]!;
|
||||
expect(mutation.type).toBe("replace-with-keyframes");
|
||||
const keyframes = mutation.keyframes as Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number>;
|
||||
}>;
|
||||
expect(keyframes.map((k) => k.percentage)).toEqual([0, 50, 100]);
|
||||
expect(keyframes.map((k) => k.properties.rotation)).toEqual([20, 30, 50]);
|
||||
});
|
||||
|
||||
it("materializes a flat tween into a 2-point range before shifting", async () => {
|
||||
const anim = {
|
||||
id: "#box-fade",
|
||||
targetSelector: "#box",
|
||||
method: "fromTo",
|
||||
resolvedStart: 0,
|
||||
duration: 1,
|
||||
properties: { opacity: 1 },
|
||||
fromProperties: { opacity: 0 },
|
||||
} as unknown as GsapAnimation;
|
||||
|
||||
const { mutations, callbacks } = recordingCallbacks();
|
||||
// Nearest keyframe to pct=100 is the synthesized 100% stop (opacity 1) —
|
||||
// dragging opacity to 0.5 is a -0.5 delta, applied to both stops.
|
||||
await commitWholePropertyOffset(
|
||||
selection(),
|
||||
anim,
|
||||
{ opacity: 0.5 },
|
||||
100,
|
||||
null,
|
||||
callbacks,
|
||||
"Fade",
|
||||
);
|
||||
|
||||
expect(mutations).toHaveLength(1);
|
||||
const keyframes = mutations[0]!.keyframes as Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number>;
|
||||
}>;
|
||||
expect(keyframes).toEqual([
|
||||
{ percentage: 0, properties: { opacity: -0.5 } },
|
||||
{ percentage: 100, properties: { opacity: 0.5 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves keyframes' other properties and per-keyframe ease untouched", async () => {
|
||||
const anim = {
|
||||
id: "#box-move",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
resolvedStart: 0,
|
||||
duration: 1,
|
||||
keyframes: {
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, opacity: 1 }, ease: "power1.in" },
|
||||
{ percentage: 100, properties: { x: 100, opacity: 0.5 } },
|
||||
],
|
||||
},
|
||||
} as unknown as GsapAnimation;
|
||||
|
||||
const { mutations, callbacks } = recordingCallbacks();
|
||||
await commitWholePropertyOffset(selection(), anim, { x: 120 }, 100, null, callbacks, "Move");
|
||||
|
||||
const keyframes = mutations[0]!.keyframes as Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number>;
|
||||
ease?: string;
|
||||
}>;
|
||||
// Delta is +20 (120 - 100 at the nearest, 100%, keyframe).
|
||||
expect(keyframes[0]).toEqual({
|
||||
percentage: 0,
|
||||
properties: { x: 20, opacity: 1 },
|
||||
ease: "power1.in",
|
||||
});
|
||||
expect(keyframes[1]).toEqual({ percentage: 100, properties: { x: 120, opacity: 0.5 } });
|
||||
});
|
||||
|
||||
it("persists a flat update instead of crashing when the tween has no synthesizable shape", async () => {
|
||||
// Regression: removeAllKeyframesFromScript collapses a keyframed tween into a
|
||||
// zero-duration immediateRender hold — synthesizeFlatTweenKeyframes treats that
|
||||
// as a static hold (returns null), so `kfs` is empty. Resizing this element with
|
||||
// auto-keyframe off used to call `kfs.reduce(...)` with no initial value, which
|
||||
// throws "Reduce of empty array with no initial value".
|
||||
const anim = {
|
||||
id: "#box-hold",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
resolvedStart: 0,
|
||||
duration: 0,
|
||||
properties: { width: 200 },
|
||||
extras: { immediateRender: "__raw:true" },
|
||||
} as unknown as GsapAnimation;
|
||||
|
||||
const { mutations, callbacks } = recordingCallbacks();
|
||||
await expect(
|
||||
commitWholePropertyOffset(selection(), anim, { width: 300 }, 0, null, callbacks, "Resize"),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mutations).toEqual([
|
||||
{ type: "update-properties", animationId: "#box-hold", properties: { width: 300 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* commitWholePropertyOffset — extracted from gsapDragCommit.ts to keep file
|
||||
* sizes under the 600-line limit (mirrors gsapDragPositionCommit.ts).
|
||||
*/
|
||||
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 { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
import { materializeIfDynamic, type GsapDragCommitCallbacks } from "./gsapDragCommit";
|
||||
|
||||
/**
|
||||
* Generic sibling of commitWholePathOffset for property groups other than
|
||||
* position (rotation, size, scale) — shift every keyframe of `anim` by the
|
||||
* same delta for each key in `newValues`, preserving the tween's shape. The
|
||||
* delta is computed against the keyframe NEAREST `currentPct` (the one an
|
||||
* ordinary edit would otherwise overwrite), not the live DOM: by the time a
|
||||
* drag gesture reaches its commit step, the preview has often already
|
||||
* gsap.set the dragged property to its new value, so the DOM can no longer
|
||||
* tell "old" from "new".
|
||||
*/
|
||||
export async function commitWholePropertyOffset(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
newValues: Record<string, number>,
|
||||
currentPct: number,
|
||||
iframe: HTMLIFrameElement | null,
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
let effectiveAnim = anim;
|
||||
if (anim.keyframes) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
||||
if (newId) effectiveAnim = { ...anim, id: newId };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const kfs =
|
||||
effectiveAnim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(effectiveAnim)?.keyframes;
|
||||
if (!kfs || kfs.length === 0) {
|
||||
// A `to()`/`from()` collapsed to a zero-duration immediateRender hold (what
|
||||
// removeAllKeyframesFromScript leaves behind) has no shape to preserve —
|
||||
// just persist the flat value instead of replacing with an empty keyframe list.
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "update-properties", animationId: effectiveAnim.id, properties: newValues },
|
||||
{ label, softReload: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const nearest = kfs.reduce((best, kf) =>
|
||||
Math.abs(kf.percentage - currentPct) < Math.abs(best.percentage - currentPct) ? kf : best,
|
||||
);
|
||||
|
||||
const shifted = kfs.map((kf) => {
|
||||
const properties = { ...kf.properties };
|
||||
for (const key of keys) {
|
||||
properties[key] = roundTo3(
|
||||
at(properties, key) + (newValues[key]! - at(nearest.properties, key)),
|
||||
);
|
||||
}
|
||||
return { percentage: kf.percentage, properties, ...(kf.ease ? { ease: kf.ease } : {}) };
|
||||
});
|
||||
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: effectiveAnim.id,
|
||||
targetSelector: effectiveAnim.targetSelector,
|
||||
position: roundTo3(ts ?? 0),
|
||||
duration: roundTo3(td || 1),
|
||||
keyframes: shifted,
|
||||
ease,
|
||||
},
|
||||
{ label, softReload: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 0 });
|
||||
});
|
||||
|
||||
const selection = { id: "box", selector: "#box" } as DomEditSelection;
|
||||
|
||||
const keyframedAnim = {
|
||||
id: "#box-to-position",
|
||||
targetSelector: "#box",
|
||||
propertyGroup: "position",
|
||||
method: "to",
|
||||
properties: {},
|
||||
resolvedStart: 0,
|
||||
duration: 2,
|
||||
keyframes: {
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
},
|
||||
} as unknown as GsapAnimation;
|
||||
|
||||
type Commit = (
|
||||
selection: DomEditSelection,
|
||||
props: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
|
||||
/** Renders the hook and hands its commit function to the caller via a ref callback. */
|
||||
function renderCommitHook(
|
||||
mutations: Array<Record<string, unknown>>,
|
||||
onReady: (commit: Commit) => void,
|
||||
) {
|
||||
function Harness() {
|
||||
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
|
||||
selectedGsapAnimations: [keyframedAnim],
|
||||
gsapCommitMutation: async (_sel, mutation) => {
|
||||
mutations.push(mutation);
|
||||
},
|
||||
addGsapAnimation: vi.fn(),
|
||||
convertToKeyframes: vi.fn(),
|
||||
previewIframeRef: { current: null },
|
||||
bumpGsapCache: vi.fn(),
|
||||
});
|
||||
onReady(commitAnimatedProperties);
|
||||
return null;
|
||||
}
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
// Regression (#1808): a "3D transform" / design-panel property edit on an
|
||||
// element that already has a keyframed tween is the ACTUAL path a manual
|
||||
// canvas nudge exercises (not the raw drag intercept) — with auto-keyframe
|
||||
// off, it must shift the whole tween instead of adding/updating a keyframe
|
||||
// at the playhead.
|
||||
describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
|
||||
it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
|
||||
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 0 });
|
||||
const mutations: Array<Record<string, unknown>> = [];
|
||||
let commit: Commit | undefined;
|
||||
const root = renderCommitHook(mutations, (fn) => (commit = fn));
|
||||
|
||||
await act(async () => {
|
||||
await commit!(selection, { x: 50 });
|
||||
});
|
||||
|
||||
expect(mutations).toHaveLength(1);
|
||||
expect(mutations[0]!.type).toBe("replace-with-keyframes");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("still updates a keyframe at the playhead when the toggle is on (default)", async () => {
|
||||
const mutations: Array<Record<string, unknown>> = [];
|
||||
let commit: Commit | undefined;
|
||||
const root = renderCommitHook(mutations, (fn) => (commit = fn));
|
||||
|
||||
await act(async () => {
|
||||
await commit!(selection, { x: 50 });
|
||||
});
|
||||
|
||||
expect(mutations.some((m) => m.type === "update-keyframe" || m.type === "add-keyframe")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(mutations.some((m) => m.type === "replace-with-keyframes")).toBe(false);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import type { SetPatchProps } from "./gsapRuntimePatch";
|
||||
import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
||||
|
||||
interface CommitAnimatedPropertyDeps {
|
||||
selectedGsapAnimations: GsapAnimation[];
|
||||
@@ -341,6 +342,27 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
||||
// keyframe would never land (the bug: scrolling depth on a keyframed element
|
||||
// just changed the constant instead of dropping a keyframe).
|
||||
if (elementHasKeyframes && anim) {
|
||||
// With auto-keyframe off (#1808), nudge the whole tween instead of
|
||||
// adding/updating a keyframe at the playhead.
|
||||
if (!usePlayerStore.getState().autoKeyframeEnabled) {
|
||||
const pct = computeElementPercentage(
|
||||
usePlayerStore.getState().currentTime,
|
||||
selection,
|
||||
anim,
|
||||
);
|
||||
await commitWholePropertyOffset(
|
||||
selection,
|
||||
anim,
|
||||
Object.fromEntries(
|
||||
propEntries.filter((e): e is [string, number] => typeof e[1] === "number"),
|
||||
),
|
||||
pct,
|
||||
iframe,
|
||||
{ commitMutation: gsapCommitMutation },
|
||||
`Edit ${primaryProp} (whole animation)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await commitKeyframeProps(
|
||||
selection,
|
||||
anim,
|
||||
|
||||
@@ -228,6 +228,9 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
|
||||
}
|
||||
|
||||
if (event.key === "s" && !event.altKey) {
|
||||
// Reserve bare `s` for Split even when the current selection cannot split,
|
||||
// so secondary listeners do not reinterpret the same key as Snap toggle.
|
||||
event.preventDefault();
|
||||
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
||||
if (selectedElementId) {
|
||||
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
|
||||
@@ -237,7 +240,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
|
||||
currentTime > el.start &&
|
||||
currentTime < el.start + el.duration
|
||||
) {
|
||||
event.preventDefault();
|
||||
void cb.handleTimelineElementSplit(el, currentTime);
|
||||
return;
|
||||
}
|
||||
@@ -245,7 +247,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
|
||||
// that isn't in the raw `elements` list, so the s-key can't resolve them.
|
||||
// Nudge toward the razor tool instead of failing silently.
|
||||
if (!el && selectedElementId.includes("#")) {
|
||||
event.preventDefault();
|
||||
cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("applyPreviewSync", () => {
|
||||
|
||||
// reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
|
||||
// CDN load failure escalates to a full reload — but it is NOT called eagerly.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// A successful instant patch is the fast path; here it missed → fallback event.
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
@@ -113,7 +113,7 @@ describe("applyPreviewSync", () => {
|
||||
|
||||
// U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
|
||||
// is correct, so we must NOT escalate to a full reload.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// Telemetry records the suppressed transient (escalated: false).
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
@@ -143,7 +143,7 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
// Structural failure: the preview is genuinely stale/broken → full reload.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -167,7 +167,7 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// "applied" emits no telemetry (only the failure paths do).
|
||||
expect(trackStudioEvent).not.toHaveBeenCalled();
|
||||
@@ -185,7 +185,7 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
// onAsyncFailure is wired, but the transient result does not trigger it.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -204,7 +204,7 @@ describe("applyPreviewSync", () => {
|
||||
reloadPreview,
|
||||
);
|
||||
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -345,7 +345,7 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -360,7 +360,7 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
});
|
||||
|
||||
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
|
||||
import type { SoftReloadResult } from "../utils/gsapSoftReload";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
@@ -66,7 +67,11 @@ function softReloadOrEscalate(
|
||||
reloadPreview: () => void,
|
||||
origin: "preview_sync" | "sdk_refresh",
|
||||
): void {
|
||||
const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview);
|
||||
// Seek the rebuilt timeline to the studio's own authoritative scrub position,
|
||||
// not the iframe's raw `__player.getTime()` — see the comment in
|
||||
// applySoftReload for why the two can desync after a keyframe-node drag.
|
||||
const currentTime = usePlayerStore.getState().currentTime;
|
||||
const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview, currentTime);
|
||||
if (result === "applied") return;
|
||||
trackStudioEvent("gsap_soft_reload_outcome", {
|
||||
origin,
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, isValidElement, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AudioWaveform } from "../player/components/AudioWaveform";
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
import { normalizeCompositionSrc } from "./useRenderClipContent";
|
||||
import { useRenderClipContent } from "./useRenderClipContent";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("normalizeCompositionSrc", () => {
|
||||
const origin = "http://localhost:5190";
|
||||
@@ -48,3 +61,43 @@ describe("normalizeCompositionSrc", () => {
|
||||
expect(result).toBe("compositions/scenes/hero.html");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRenderClipContent", () => {
|
||||
function renderClipContent(el: TimelineElement): ReactNode {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
let content: ReactNode = null;
|
||||
|
||||
function Harness() {
|
||||
const render = useRenderClipContent({
|
||||
projectIdRef: { current: "my-project" },
|
||||
compIdToSrc: new Map(),
|
||||
activePreviewUrl: "/api/projects/my-project/preview",
|
||||
effectiveTimelineDuration: 12,
|
||||
});
|
||||
content = render(el, { clip: "#222", label: "#fff" });
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
act(() => root.unmount());
|
||||
return content;
|
||||
}
|
||||
|
||||
it("renders audio clips as waveforms even when a composition preview URL is active", () => {
|
||||
const content = renderClipContent({
|
||||
id: "voiceover",
|
||||
tag: "audio",
|
||||
start: 1,
|
||||
duration: 4,
|
||||
track: 1,
|
||||
src: "assets/voiceover.mp3",
|
||||
});
|
||||
|
||||
expect(isValidElement(content)).toBe(true);
|
||||
if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,13 @@ export function useRenderClipContent({
|
||||
});
|
||||
}
|
||||
|
||||
// Audio clips — waveform visualization. Resolve these before the generic
|
||||
// activePreviewUrl thumbnail branch; audio rows need waveform data, not a
|
||||
// captured frame from the currently drilled composition preview.
|
||||
if (el.tag === "audio") {
|
||||
return renderAudioClip(el, pid, style.label);
|
||||
}
|
||||
|
||||
// When drilled into a composition, render all inner elements via
|
||||
// CompositionThumbnail at their start time — most accurate visual.
|
||||
if (activePreviewUrl && el.duration > 0) {
|
||||
@@ -131,11 +138,6 @@ export function useRenderClipContent({
|
||||
el.duration < effectiveTimelineDuration * 0.92 &&
|
||||
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
|
||||
|
||||
// Audio clips — waveform visualization
|
||||
if (el.tag === "audio") {
|
||||
return renderAudioClip(el, pid, style.label);
|
||||
}
|
||||
|
||||
if ((el.tag === "video" || el.tag === "img") && el.src) {
|
||||
const mediaSrc = el.src.startsWith("http")
|
||||
? el.src
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
Timeline,
|
||||
formatTimelineTickLabel,
|
||||
@@ -54,6 +54,56 @@ describe("Timeline provider boundary", () => {
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("opens the keyframe context menu without seeking to that keyframe", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 720,
|
||||
});
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
currentTime: 0.25,
|
||||
selectedElementId: "clip-1",
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 4, track: 0 }],
|
||||
keyframeCache: new Map([
|
||||
[
|
||||
"clip-1",
|
||||
{
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const onSeek = vi.fn();
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline, { onSeek }));
|
||||
});
|
||||
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2,
|
||||
clientX: 120,
|
||||
clientY: 40,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSeek).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateTicks", () => {
|
||||
|
||||
@@ -487,8 +487,6 @@ export const Timeline = memo(function Timeline({
|
||||
if (el) {
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
const absTime = el.start + (pct / 100) * el.duration;
|
||||
onSeek?.(absTime);
|
||||
}
|
||||
const kfData = keyframeCache.get(elId);
|
||||
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
||||
|
||||
@@ -446,6 +446,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
</TimelineClip>
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function pointerEvent(type: string, init: PointerEventInit): Event {
|
||||
if (typeof PointerEvent === "function") return new PointerEvent(type, init);
|
||||
return new MouseEvent(type, init);
|
||||
}
|
||||
|
||||
function renderDiamonds(onClickKeyframe = vi.fn()) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { host, root, onClickKeyframe };
|
||||
}
|
||||
|
||||
describe("TimelineClipDiamonds", () => {
|
||||
it("treats primary pointerup without drag as a keyframe click", () => {
|
||||
const { host, root, onClickKeyframe } = renderDiamonds();
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
});
|
||||
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(50);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not treat secondary pointerup as a keyframe click", () => {
|
||||
const { host, root, onClickKeyframe } = renderDiamonds();
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 2 }));
|
||||
});
|
||||
|
||||
expect(onClickKeyframe).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// Regression: once the clip is selected, canDrag arms on every diamond
|
||||
// press. A real click's few px of mouse/trackpad jitter then resolves (via
|
||||
// the neighbour clamp) back onto ~the same position — "noop", not "move" —
|
||||
// which fell through neither branch and silently did nothing: no
|
||||
// selection, no retime. It must still count as the click it was.
|
||||
it("treats a drag-armed press that resolves to a no-op move as a click", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const onMoveKeyframe = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
}}
|
||||
clipWidthPx={5000}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
// 4px of travel at a 5000px clip width is ~0.08 clip-% — above the drag
|
||||
// threshold (so resolveKeyframeDrag doesn't short-circuit to "click"
|
||||
// itself) but below the no-op epsilon once neighbour-clamped.
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
||||
});
|
||||
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(50);
|
||||
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// Regression: a genuine retime (drag far enough to actually move the
|
||||
// keyframe) committed the move but never selected/parked on the result —
|
||||
// the diamond it was just dragged looked exactly like one nothing happened
|
||||
// to. Select it at its NEW position too.
|
||||
it("selects the keyframe at its new position after a real drag-retime", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const onMoveKeyframe = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
// 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon,
|
||||
// a real retime.
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
||||
});
|
||||
|
||||
expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52);
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(52);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// Regression: onClickKeyframe's state updates can re-render the diamond
|
||||
// button out from under the gesture before the browser auto-synthesizes the
|
||||
// "click" event that follows a button's pointerdown+pointerup. That orphaned
|
||||
// click then bubbles to the ancestor clip's onClick, which toggles selection
|
||||
// off whenever the clip is already selected — the state a diamond click
|
||||
// always happens in — so every keyframe click immediately deselected its
|
||||
// own clip. suppressClickRef lets that ancestor ignore the stray click.
|
||||
it("arms suppressClickRef synchronously on a keyframe click", () => {
|
||||
const suppressClickRef = { current: false };
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 50, properties: { x: 100 } }],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={vi.fn()}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
});
|
||||
|
||||
expect(suppressClickRef.current).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,10 @@ interface TimelineClipDiamondsProps {
|
||||
fromClipPercentage: number,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
/** Set while resolving a diamond press so the ancestor clip's onClick (which
|
||||
* toggles selection off when already selected) ignores the native "click"
|
||||
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
|
||||
suppressClickRef?: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
const DIAMOND_RATIO = 0.8;
|
||||
@@ -76,6 +80,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
onMoveKeyframe,
|
||||
suppressClickRef,
|
||||
}: TimelineClipDiamondsProps) {
|
||||
// Hooks must run before the early return below.
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
@@ -83,6 +88,21 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
||||
// on drop re-keys the diamond from source.
|
||||
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
||||
// The button element can re-render (reposition/unmount) synchronously from
|
||||
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
||||
// browser gets to auto-synthesize the "click" event that normally follows
|
||||
// pointerdown+pointerup on a button. That orphaned click then fires on
|
||||
// whatever ancestor is still there — the clip wrapper — whose own onClick
|
||||
// toggles selection off when the clip is already selected (the state a
|
||||
// diamond click always happens in). Suppressing it here is the same fix
|
||||
// already used for clip drag/resize in useTimelineClipDrag.ts.
|
||||
const suppressNextClick = () => {
|
||||
if (!suppressClickRef) return;
|
||||
suppressClickRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
suppressClickRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
if (clipWidthPx < 20) return null;
|
||||
|
||||
@@ -102,7 +122,19 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
const canDrag = isSelected && !!onMoveKeyframe;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
// Above the clip's trim-handle strips (TimelineClip.tsx, z-index 4) so
|
||||
// a keyframe sitting in the first/last ~14px of the clip stays
|
||||
// clickable instead of being covered by the resize handle. This div
|
||||
// establishes its own stacking context (position + z-index), so the
|
||||
// diamonds' own z-index (1/2) can't escape it on their own — the bump
|
||||
// has to happen here.
|
||||
zIndex: 5,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{sorted.map((kf, i) => {
|
||||
if (i === 0) return null;
|
||||
const prev = sorted[i - 1]!;
|
||||
@@ -178,6 +210,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
const d = dragRef.current;
|
||||
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
||||
if (!d || d.kfKey !== kfKey) {
|
||||
if (e.button !== 0) return;
|
||||
suppressNextClick();
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
return;
|
||||
@@ -186,6 +220,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
dragRef.current = null;
|
||||
setPreview(null);
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
suppressNextClick();
|
||||
const res = resolveKeyframeDrag({
|
||||
pointerDownX: d.startX,
|
||||
pointerUpX: e.clientX,
|
||||
@@ -194,11 +229,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
draggedIndex: i,
|
||||
sortedClipPcts,
|
||||
});
|
||||
if (res.kind === "click") {
|
||||
if (res.kind === "click" || res.kind === "noop") {
|
||||
// "noop" is a press with enough pointer jitter to arm a drag (canDrag
|
||||
// is on for every diamond once the clip is selected) that resolved
|
||||
// back onto ~the same position — no real retime, so treat it as the
|
||||
// click it was. Otherwise a normal click with a few px of mouse/
|
||||
// trackpad drift silently does nothing: no selection, no move.
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
} else if (res.kind === "move" && res.toClipPct != null) {
|
||||
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
|
||||
// A retime still targeted this exact diamond — park/select it at its
|
||||
// new position, same as a plain click, or a drag that actually moved
|
||||
// something looks identical to one that silently did nothing.
|
||||
onClickKeyframe?.(res.toClipPct);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -104,6 +104,12 @@ interface PlayerState {
|
||||
setMotionPathArmed: (armed: boolean) => void;
|
||||
motionPathCreateAvailable: boolean;
|
||||
setMotionPathCreateAvailable: (available: boolean) => void;
|
||||
/** Global toggle for the "Add keyframe" diamond in the timeline toolbar (#1808).
|
||||
* When false, a manual drag/resize/rotate edit on an element that already has
|
||||
* a live tween shifts every keyframe by the edit's delta (preserving the
|
||||
* animation's shape) instead of inserting/updating a keyframe at the playhead. */
|
||||
autoKeyframeEnabled: boolean;
|
||||
setAutoKeyframeEnabled: (enabled: boolean) => void;
|
||||
|
||||
/** Multi-select: additional selected elements beyond selectedElementId. */
|
||||
selectedElementIds: Set<string>;
|
||||
@@ -238,6 +244,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
|
||||
motionPathCreateAvailable: false,
|
||||
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
|
||||
autoKeyframeEnabled: true,
|
||||
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
|
||||
|
||||
selectedElementIds: new Set<string>(),
|
||||
toggleSelectedElementId: (id: string) =>
|
||||
|
||||
@@ -100,6 +100,18 @@ describe("applySoftReload", () => {
|
||||
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeks to the caller-supplied currentTime override instead of the iframe's own __player.getTime()", () => {
|
||||
// Regression: the iframe's raw __player.getTime() (2.0 here, per the mock)
|
||||
// can desync from the studio's authoritative scrub position — e.g. a
|
||||
// keyframe-node drag parks the playhead via the store before this reload's
|
||||
// async commit resolves. The rebuilt timeline must re-seek to the caller's
|
||||
// value, not the iframe's possibly-stale one.
|
||||
const { iframe, contentWindow } = buildMockIframe();
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT, undefined, 0);
|
||||
expect(result).toBe("applied");
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => {
|
||||
// Repro: an element dragged via gsap.set whose keyframes were then removed is
|
||||
// no longer a timeline child, so the timeline-children sweep misses it. Its
|
||||
|
||||
@@ -175,6 +175,7 @@ export function applySoftReload(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
scriptText: string,
|
||||
onAsyncFailure?: () => void,
|
||||
currentTimeOverride?: number,
|
||||
): SoftReloadResult {
|
||||
if (!iframe || !scriptText) return "cannot-soft-reload";
|
||||
|
||||
@@ -210,7 +211,14 @@ export function applySoftReload(
|
||||
// rather than killing the target timeline and appending an orphan script.
|
||||
if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload";
|
||||
|
||||
const currentTime = win.__player?.getTime?.() ?? 0;
|
||||
// Prefer the caller-supplied scrub position (the studio's own authoritative
|
||||
// currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`:
|
||||
// the two can desync (a keyframe-node drag parks the playhead via the store
|
||||
// BEFORE this reload's async commit resolves, and the iframe's own GSAP clock
|
||||
// doesn't reliably reflect that yet), which re-seeks the freshly rebuilt
|
||||
// timeline to the wrong frame and leaves the element (and its overlay)
|
||||
// rendered at a stale/unrelated position.
|
||||
const currentTime = currentTimeOverride ?? win.__player?.getTime?.() ?? 0;
|
||||
|
||||
// Track whether the MotionPath async path was taken. When it is, the script
|
||||
// executes inside pluginScript.onload — after applySoftReload has already
|
||||
@@ -300,8 +308,13 @@ export function applySoftReload(
|
||||
const s = doc.createElement("script");
|
||||
s.textContent = `(function(){${scriptText}\n})();`;
|
||||
doc.body.appendChild(s);
|
||||
win.__hfForceTimelineRebind?.();
|
||||
// Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
|
||||
// (see init.ts) renders the freshly-created timeline at whatever the
|
||||
// runtime's internal scrub position already is, not at whatever we pass
|
||||
// here afterward — a redundant seek() call after rebind can be a GSAP
|
||||
// no-op if the timeline already reports being at that time internally.
|
||||
win.__player?.seek?.(currentTime);
|
||||
win.__hfForceTimelineRebind?.();
|
||||
win.__hfStudioManualEditsApply?.();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user