mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
Review follow-ups on the expanded keyframe lanes. Writer: - `onMoveKeyframe`'s flat-tween boundary branch answered `true` the moment it dispatched update-meta, so a rejected write left the diamond parked at its drop position. `observeGsapMutation` now resolves to whether the mutation landed and the boundary branch returns it, matching the other branches. - "Delete All Keyframes" cleared only the first keyframed tween on the layer, so a layer with position AND opacity keyframes kept half of them. It now walks every keyframed tween, serially, through the clicked element's selection. - The post-convert lookup in `commitFlatViaKeyframes` matched by target selector, which picks an arbitrary tween when a target carries several. Match by id first. Interaction and a11y: - A rejected retime whose commit settled after a newer drag reverted the selection to its own source keyframe, undoing a retime the user could see. The revert now only runs while it is still the lane's latest gesture. - Diamonds key on the authored identity instead of index plus rendered clip-%, so a neighbour's retime no longer remounts the button mid-drag. - The disclosure caret gets `aria-controls` on an always-mounted lanes container, and both it and the property-group toggle grow to the 24x24 WCAG 2.2 minimum. - `LayerDisclosureRow` takes the same adaptive `columnWidth` as its sibling lane rows instead of hardcoding LABEL_COL_W over the canvas. Test locks: - The timeline callbacks harness resolves a DISTINCT selection per element, so the clicked-element writes are actually pinned; three assertions that passed either way now name the clicked element's selection. - New: null-selection aborts every mutation, delete-all covers both tweens, a rejected boundary retime reports `false`, and a stale revert leaves selection. - The playhead-percentage assertion checks 25, not `expect.any(Number)` (which also accepts NaN); ease segments assert their label ORDER, not just that the three curves differ; the collapsed-diamond callback asserts the whole target. - Dropped a duplicate `selection override` describe left by a rebase.
492 lines
17 KiB
TypeScript
492 lines
17 KiB
TypeScript
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
|
|
import { roundTo3 } from "../utils/rounding";
|
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
import {
|
|
type GsapDragCommitCallbacks,
|
|
computeCurrentPercentage,
|
|
parkPlayheadOnKeyframe,
|
|
materializeIfDynamic,
|
|
} from "./gsapDragCommit";
|
|
|
|
/**
|
|
* The tween's keyframes with one inserted at `percentage`. Any existing keyframe
|
|
* within {@link KEYFRAME_PCT_MATCH} of the insert is REPLACED, not kept: the
|
|
* server takes a replace-with-keyframes list verbatim, so an append-only build
|
|
* could hand it two keyframes a fraction of a percent apart. The invariant lives
|
|
* here rather than in each caller's own pre-check, which is how the two callers
|
|
* ended up with different tolerances in the first place.
|
|
*/
|
|
export function buildTemporalArcKeyframes(
|
|
anim: GsapAnimation,
|
|
percentage: number,
|
|
properties: Record<string, number>,
|
|
) {
|
|
return [
|
|
...(anim.keyframes?.keyframes ?? [])
|
|
.filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH)
|
|
.map((keyframe) => ({
|
|
percentage: keyframe.percentage,
|
|
properties: { ...keyframe.properties },
|
|
...(keyframe.ease ? { ease: keyframe.ease } : {}),
|
|
})),
|
|
{ percentage, properties },
|
|
].sort((a, b) => a.percentage - b.percentage);
|
|
}
|
|
|
|
async function extendTweenAndAddKeyframe(
|
|
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)`, softReload: true, beforeReload },
|
|
);
|
|
}
|
|
|
|
// 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> = {};
|
|
for (const key of Object.keys(properties)) {
|
|
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);
|
|
}
|
|
|
|
// fallow-ignore-next-line code-duplication
|
|
// fallow-ignore-next-line complexity
|
|
export async function commitGsapPositionFromDrag(
|
|
selection: DomEditSelection,
|
|
anim: GsapAnimation,
|
|
studioOffset: { x: number; y: number },
|
|
gsapPos: { x: number; y: number },
|
|
iframe: HTMLIFrameElement | null,
|
|
selector: string,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
): Promise<void> {
|
|
const el = selection.element;
|
|
// fallow-ignore-next-line code-duplication
|
|
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
el,
|
|
studioOffset,
|
|
gsapPos,
|
|
);
|
|
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
|
|
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
|
|
const restoreOffset = () => {
|
|
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
|
|
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
|
|
el.removeAttribute("data-hf-drag-initial-offset-x");
|
|
el.removeAttribute("data-hf-drag-initial-offset-y");
|
|
};
|
|
|
|
const 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);
|
|
const keyframes = anim.keyframes?.keyframes ?? [];
|
|
// Same tolerance as applyArcKeyframeAtPlayhead and isMotionPathEndpoint. A
|
|
// tighter one here meant a drag that landed a fraction of a percent off an
|
|
// authored waypoint skipped the update-point branch and appended instead.
|
|
const pointIndex = keyframes.findIndex(
|
|
(kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH,
|
|
);
|
|
if (pointIndex >= 0) {
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "update-motion-path-point",
|
|
animationId: anim.id,
|
|
pointIndex,
|
|
x: newX,
|
|
y: newY,
|
|
},
|
|
{ label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset },
|
|
);
|
|
setActiveKeyframePct(null);
|
|
parkPlayheadOnKeyframe(anim, pct);
|
|
return;
|
|
}
|
|
|
|
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;
|
|
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "replace-with-keyframes",
|
|
animationId: anim.id,
|
|
targetSelector: anim.targetSelector,
|
|
position: roundTo3(tweenStart),
|
|
duration: roundTo3(tweenDuration),
|
|
keyframes: temporalKeyframes,
|
|
ease: "none",
|
|
},
|
|
{ label: "Move layer (new keyframe)", softReload: true, 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)", 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,
|
|
);
|
|
}
|
|
}
|