mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): address family B timeline review findings
- revert diamond selection when a rejected retime leaves the source in place - clear project-local ease focus and expansion on player store reset - share one static-position-hold predicate across the tween cache - invalidate the GSAP cache even when a group timing rewrite throws - stamp each lane keyframe's ease from its own source tween - memoize property lanes and row offsets so memo'd diamond lanes hold - use the editable tween duration for drag position commits - restore the pre-t=0 pad in the all-collapsed content origin - clamp the drag ghost and drop placeholder to the collapsed clip height - aria-expanded on the layer disclosure, aria-pressed plus state-specific labels on the keyframe toggle, 24px chevron targets, focus-visible parity
This commit is contained in:
@@ -93,7 +93,11 @@ export function KeyframeEaseList({
|
|||||||
? "Custom"
|
? "Custom"
|
||||||
: (EASE_LABELS[segEase] ?? segEase);
|
: (EASE_LABELS[segEase] ?? segEase);
|
||||||
return (
|
return (
|
||||||
<div key={`${i}-${kf.percentage}`} className="rounded-md bg-neutral-900/50">
|
<div
|
||||||
|
key={`${i}-${kf.percentage}`}
|
||||||
|
data-ease-segment-pct={kf.percentage}
|
||||||
|
className="rounded-md bg-neutral-900/50"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
|
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
import { usePlayerStore } from "../player/store/playerStore";
|
import { usePlayerStore } from "../player/store/playerStore";
|
||||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||||
|
import { resolveEditableTweenDuration } from "./gsapShared";
|
||||||
import { roundTo3 } from "../utils/rounding";
|
import { roundTo3 } from "../utils/rounding";
|
||||||
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
||||||
import { resolveEditableTweenDuration } from "./gsapShared";
|
|
||||||
import {
|
import {
|
||||||
type GsapDragCommitCallbacks,
|
type GsapDragCommitCallbacks,
|
||||||
computeCurrentPercentage,
|
computeCurrentPercentage,
|
||||||
@@ -159,7 +159,7 @@ async function commitFlatViaKeyframes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ct = usePlayerStore.getState().currentTime;
|
const ct = usePlayerStore.getState().currentTime;
|
||||||
const ts = resolveTweenStart(anim);
|
const ts = resolveTweenStart(anim);
|
||||||
const td = resolveTweenDuration(anim);
|
const td = resolveEditableTweenDuration(anim, selection);
|
||||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||||
const outsideRange =
|
const outsideRange =
|
||||||
activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||||
@@ -324,7 +324,7 @@ export async function commitGsapPositionFromDrag(
|
|||||||
const dragProps: Record<string, number> = { x: newX, y: newY };
|
const dragProps: Record<string, number> = { x: newX, y: newY };
|
||||||
|
|
||||||
const ts = resolveTweenStart(effectiveAnim);
|
const ts = resolveTweenStart(effectiveAnim);
|
||||||
const td = resolveTweenDuration(effectiveAnim);
|
const td = resolveEditableTweenDuration(effectiveAnim, selection);
|
||||||
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||||
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
||||||
if (outsideRange && !hasSelectedKeyframe) {
|
if (outsideRange && !hasSelectedKeyframe) {
|
||||||
@@ -352,7 +352,7 @@ export async function commitGsapPositionFromDrag(
|
|||||||
} else if (anim.method === "from" || anim.method === "fromTo") {
|
} else if (anim.method === "from" || anim.method === "fromTo") {
|
||||||
const ct = usePlayerStore.getState().currentTime;
|
const ct = usePlayerStore.getState().currentTime;
|
||||||
const ts = resolveTweenStart(anim);
|
const ts = resolveTweenStart(anim);
|
||||||
const td = resolveTweenDuration(anim);
|
const td = resolveEditableTweenDuration(anim, selection);
|
||||||
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
||||||
const outsideRange =
|
const outsideRange =
|
||||||
!hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
!hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||||
@@ -372,7 +372,7 @@ export async function commitGsapPositionFromDrag(
|
|||||||
|
|
||||||
if (existingPosAnim?.keyframes) {
|
if (existingPosAnim?.keyframes) {
|
||||||
const posTs = resolveTweenStart(existingPosAnim);
|
const posTs = resolveTweenStart(existingPosAnim);
|
||||||
const posTd = resolveTweenDuration(existingPosAnim);
|
const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
|
||||||
if (posTs !== null) {
|
if (posTs !== null) {
|
||||||
await extendTweenAndAddKeyframe(
|
await extendTweenAndAddKeyframe(
|
||||||
selection,
|
selection,
|
||||||
|
|||||||
@@ -390,4 +390,36 @@ describe("deleteSelectedKeyframes", () => {
|
|||||||
expect.objectContaining({ softReload: true }),
|
expect.objectContaining({ softReload: true }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops keyframes that belong to other elements", () => {
|
||||||
|
// A stale selection from a previously active element must not delete
|
||||||
|
// anything on the element that is active now.
|
||||||
|
usePlayerStore.setState({
|
||||||
|
selectedElementId: "card",
|
||||||
|
selectedKeyframes: new Set([
|
||||||
|
timelineKeyframeSelectionKey("card", {
|
||||||
|
percentage: 30,
|
||||||
|
tweenPercentage: 20,
|
||||||
|
propertyGroup: "position",
|
||||||
|
animationId: "card-position",
|
||||||
|
}),
|
||||||
|
timelineKeyframeSelectionKey("other", {
|
||||||
|
percentage: 70,
|
||||||
|
tweenPercentage: 80,
|
||||||
|
propertyGroup: "position",
|
||||||
|
animationId: "card-position",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const handleGsapRemoveKeyframe =
|
||||||
|
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
|
||||||
|
|
||||||
|
deleteSelectedKeyframes({
|
||||||
|
selectedGsapAnimations: [{ id: "card-position", keyframes: {} }],
|
||||||
|
handleGsapRemoveKeyframe,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[1]).toBe(20);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -330,9 +330,14 @@ export function useGsapAnimationsForElement(
|
|||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!elementId) return;
|
if (!elementId) return;
|
||||||
// No property-group filter: ungrouped tweens are recorded here as well.
|
// Same admission rule as the keyframe cache below (hold skip included) and
|
||||||
|
// no property-group filter: the two stores must agree, or a hold draws an
|
||||||
|
// expanded property lane with no collapsed diamond behind it and an
|
||||||
|
// ungrouped tween draws diamonds with no lane source.
|
||||||
const sourceAnimations = animations.filter(
|
const sourceAnimations = animations.filter(
|
||||||
(animation) => animation.keyframes || synthesizeFlatTweenKeyframes(animation),
|
(animation) =>
|
||||||
|
!isStaticPositionHold(animation) &&
|
||||||
|
(animation.keyframes || synthesizeFlatTweenKeyframes(animation)),
|
||||||
);
|
);
|
||||||
if (sourceAnimations.length > 0)
|
if (sourceAnimations.length > 0)
|
||||||
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
|
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export function useInspectorState(
|
|||||||
inspectorPanelActive,
|
inspectorPanelActive,
|
||||||
inspectorButtonActive:
|
inspectorButtonActive:
|
||||||
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
|
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
|
||||||
|
// Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path
|
||||||
|
// handles ARE the arc-drag affordance, so gating them on an open Inspector
|
||||||
|
// would make keyframe path editing reachable only from a side panel.
|
||||||
shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording,
|
shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording,
|
||||||
// Keep the selection box drawn even when the Inspector is collapsed —
|
// Keep the selection box drawn even when the Inspector is collapsed —
|
||||||
// closing the panel shouldn't visually deselect the element.
|
// closing the panel shouldn't visually deselect the element.
|
||||||
|
|||||||
@@ -312,25 +312,32 @@ export function useTimelineGroupEditing({
|
|||||||
// reload (see the trackOnly doc above). Mixed batches (any start
|
// reload (see the trackOnly doc above). Mixed batches (any start
|
||||||
// change) keep the full fallback below.
|
// change) keep the full fallback below.
|
||||||
if (trackOnly) return;
|
if (trackOnly) return;
|
||||||
await finishGroupTimingGsapFallback({
|
// The timing persist above already committed to disk, so the cached
|
||||||
projectId,
|
// GSAP read is stale whether or not the position rewrite succeeded —
|
||||||
iframe: previewIframeRef.current,
|
// invalidate on the error path too (matches the single-element path's
|
||||||
reloadPreview,
|
// `.finally`), or a failed rewrite leaves the editor reading old tweens.
|
||||||
label: "Move timeline clips",
|
try {
|
||||||
errorLabel: "Failed to shift GSAP positions",
|
await finishGroupTimingGsapFallback({
|
||||||
coalesceKey,
|
projectId,
|
||||||
recordEdit,
|
iframe: previewIframeRef.current,
|
||||||
activeCompPath,
|
reloadPreview,
|
||||||
changes,
|
label: "Move timeline clips",
|
||||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
errorLabel: "Failed to shift GSAP positions",
|
||||||
mutateChange: (change, changePath) => {
|
coalesceKey,
|
||||||
const delta = change.start - change.element.start;
|
recordEdit,
|
||||||
const domId = change.element.domId;
|
activeCompPath,
|
||||||
if (delta === 0 || !domId) return null;
|
changes,
|
||||||
return shiftGsapPositions(projectId, changePath, domId, delta);
|
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||||
},
|
mutateChange: (change, changePath) => {
|
||||||
});
|
const delta = change.start - change.element.start;
|
||||||
invalidateGsapCache?.();
|
const domId = change.element.domId;
|
||||||
|
if (delta === 0 || !domId) return null;
|
||||||
|
return shiftGsapPositions(projectId, changePath, domId, delta);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
invalidateGsapCache?.();
|
||||||
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
// Failed persist: revert the optimistic duration readout + live root
|
// Failed persist: revert the optimistic duration readout + live root
|
||||||
// alongside the gesture owner's store rollback.
|
// alongside the gesture owner's store rollback.
|
||||||
@@ -410,34 +417,40 @@ export function useTimelineGroupEditing({
|
|||||||
coalesceMs,
|
coalesceMs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await finishGroupTimingGsapFallback({
|
// See the move path: the timing persist is already on disk, so the GSAP
|
||||||
projectId,
|
// cache must be invalidated even when the position rewrite throws.
|
||||||
iframe: previewIframeRef.current,
|
try {
|
||||||
reloadPreview,
|
await finishGroupTimingGsapFallback({
|
||||||
label: "Resize timeline clips",
|
projectId,
|
||||||
errorLabel: "Failed to scale GSAP positions",
|
iframe: previewIframeRef.current,
|
||||||
coalesceKey,
|
reloadPreview,
|
||||||
recordEdit,
|
label: "Resize timeline clips",
|
||||||
activeCompPath,
|
errorLabel: "Failed to scale GSAP positions",
|
||||||
changes,
|
coalesceKey,
|
||||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
recordEdit,
|
||||||
mutateChange: (change, changePath) => {
|
activeCompPath,
|
||||||
const domId = change.element.domId;
|
changes,
|
||||||
const timingChanged =
|
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||||
change.start !== change.element.start || change.duration !== change.element.duration;
|
mutateChange: (change, changePath) => {
|
||||||
if (!timingChanged || !domId) return null;
|
const domId = change.element.domId;
|
||||||
return scaleGsapPositions(
|
const timingChanged =
|
||||||
projectId,
|
change.start !== change.element.start ||
|
||||||
changePath,
|
change.duration !== change.element.duration;
|
||||||
domId,
|
if (!timingChanged || !domId) return null;
|
||||||
change.element.start,
|
return scaleGsapPositions(
|
||||||
change.element.duration,
|
projectId,
|
||||||
change.start,
|
changePath,
|
||||||
change.duration,
|
domId,
|
||||||
);
|
change.element.start,
|
||||||
},
|
change.element.duration,
|
||||||
});
|
change.start,
|
||||||
invalidateGsapCache?.();
|
change.duration,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
invalidateGsapCache?.();
|
||||||
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
// Failed persist: revert the optimistic duration readout + live root
|
// Failed persist: revert the optimistic duration readout + live root
|
||||||
// alongside the gesture owner's store rollback.
|
// alongside the gesture owner's store rollback.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function LayerDisclosureRow({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
|
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
|
||||||
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
|
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
|
||||||
className="flex h-5 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
className="flex h-5 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||||
@@ -47,10 +48,9 @@ export function LayerDisclosureRow({
|
|||||||
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
|
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span
|
{/* Decorative: the disclosure button above already names the row's keyframe
|
||||||
aria-label="Layer keyframe indicator"
|
state, and aria-label on a plain span is not exposed reliably anyway. */}
|
||||||
className="shrink-0 text-[13px] leading-none text-white/40"
|
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
|
||||||
>
|
|
||||||
◇
|
◇
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate font-medium" title={name}>
|
<span className="min-w-0 flex-1 truncate font-medium" title={name}>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ function renderBasicTimeline() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Timeline provider boundary", () => {
|
describe("Timeline provider boundary", () => {
|
||||||
it("keeps all-collapsed horizontal positions at the 32px gutter", () => {
|
it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
duration: 11,
|
duration: 11,
|
||||||
timelineReady: true,
|
timelineReady: true,
|
||||||
@@ -121,13 +121,13 @@ describe("Timeline provider boundary", () => {
|
|||||||
const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
|
const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
|
||||||
renderTimelineGeometry("clip-1");
|
renderTimelineGeometry("clip-1");
|
||||||
|
|
||||||
expect(trackHeader.style.width).toBe("32px");
|
expect(trackHeader.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
|
||||||
expect(clip.style.left).toBe("1000px");
|
expect(clip.style.left).toBe("1000px");
|
||||||
expect(clip.style.height).toBe("");
|
expect(clip.style.height).toBe("");
|
||||||
expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
|
expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
|
||||||
expect(rulerOrigin.style.width).toBe("32px");
|
expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
|
||||||
expect(rulerTick.style.left).toBe("999.5px");
|
expect(rulerTick.style.left).toBe("999.5px");
|
||||||
expect(playhead.style.left).toBe(`${1032 - PLAYHEAD_HEAD_W / 2}px`);
|
expect(playhead.style.left).toBe(`${GUTTER + TRACKS_LEFT_PAD + 1000 - PLAYHEAD_HEAD_W / 2}px`);
|
||||||
expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`);
|
expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`);
|
||||||
expect(
|
expect(
|
||||||
resolveTimelineAssetDrop(
|
resolveTimelineAssetDrop(
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
|||||||
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
||||||
import { useTimelineGeometry } from "./useTimelineGeometry";
|
import { useTimelineGeometry } from "./useTimelineGeometry";
|
||||||
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
||||||
import { GUTTER, LABEL_COL_W, generateTicks } from "./timelineLayout";
|
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout";
|
||||||
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
||||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||||
@@ -127,7 +127,10 @@ export const Timeline = memo(function Timeline({
|
|||||||
[gsapAnimations],
|
[gsapAnimations],
|
||||||
);
|
);
|
||||||
const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips;
|
const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips;
|
||||||
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER;
|
// Without the label column the pre-t=0 breathing room is still TRACKS_LEFT_PAD
|
||||||
|
// (dropping it would jam clip 0 against the gutter on every non-keyframed
|
||||||
|
// composition); in label mode the 232px label column already provides it.
|
||||||
|
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
||||||
const contentGutter = labelMode ? GUTTER : 0;
|
const contentGutter = labelMode ? GUTTER : 0;
|
||||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|||||||
const draggedRowIndex =
|
const draggedRowIndex =
|
||||||
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
||||||
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
|
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
|
||||||
|
// A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
|
||||||
|
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
|
||||||
|
// ghost and drop placeholder must clamp to it or they stretch to the full
|
||||||
|
// expanded row height and stop matching the clip being dragged.
|
||||||
|
const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2;
|
||||||
const {
|
const {
|
||||||
onResizeElement,
|
onResizeElement,
|
||||||
onMoveElement,
|
onMoveElement,
|
||||||
@@ -177,7 +182,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|||||||
top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
|
top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
|
||||||
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
||||||
width: Math.max(draggedClip.element.duration * props.pps, 4),
|
width: Math.max(draggedClip.element.duration * props.pps, 4),
|
||||||
height: draggedRowHeight - CLIP_Y * 2,
|
height: draggedClipHeight,
|
||||||
border: "1px solid rgba(60,230,172,0.55)",
|
border: "1px solid rgba(60,230,172,0.55)",
|
||||||
background: "rgba(60,230,172,0.12)",
|
background: "rgba(60,230,172,0.12)",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
@@ -230,7 +235,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|||||||
top: activeDraggedPosition.top,
|
top: activeDraggedPosition.top,
|
||||||
left: activeDraggedPosition.left,
|
left: activeDraggedPosition.left,
|
||||||
width: Math.max(activeDraggedElement.duration * props.pps, 4),
|
width: Math.max(activeDraggedElement.duration * props.pps, 4),
|
||||||
height: draggedRowHeight - CLIP_Y * 2,
|
height: draggedClipHeight,
|
||||||
zIndex: 40,
|
zIndex: 40,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -375,12 +375,23 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|||||||
pendingRetimeRef.current.delete(kfKey);
|
pendingRetimeRef.current.delete(kfKey);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// A rejected drop (the destination time is already occupied) snaps
|
||||||
|
// the diamond back to its source position, so the pending entry AND
|
||||||
|
// the selection have to revert with it — parking on the ghost drop
|
||||||
|
// position strands the playhead + selection on a keyframe that does
|
||||||
|
// not exist there.
|
||||||
|
const revertRetime = () => {
|
||||||
|
clearPending();
|
||||||
|
onClickKeyframe?.(fromTarget);
|
||||||
|
};
|
||||||
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
|
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
|
||||||
if (!committed) clearPending();
|
if (!committed) revertRetime();
|
||||||
}, clearPending);
|
}, revertRetime);
|
||||||
// A retime still targeted this exact diamond — park/select it at its
|
// 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
|
// new position, same as a plain click, or a drag that actually moved
|
||||||
// something looks identical to one that silently did nothing.
|
// something looks identical to one that silently did nothing. Done
|
||||||
|
// optimistically so the gesture stays responsive; revertRetime puts
|
||||||
|
// it back if the move is rejected.
|
||||||
onClickKeyframe?.({
|
onClickKeyframe?.({
|
||||||
...target,
|
...target,
|
||||||
percentage: res.toClipPct,
|
percentage: res.toClipPct,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
|
import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
classifyPropertyGroup,
|
classifyPropertyGroup,
|
||||||
type GsapAnimation,
|
type GsapAnimation,
|
||||||
@@ -59,6 +59,14 @@ function sourceGroups(animations: readonly GsapAnimation[]) {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve the ease from THIS keyframe's own source tween. A lane can merge
|
||||||
|
* several tweens, so a shared lane-level fallback would label a segment with a
|
||||||
|
* different animation's ease than the one the ease editor targets (it routes
|
||||||
|
* by animationId). */
|
||||||
|
function keyframeEase(keyframe: { ease?: string }, animation: GsapAnimation): string | undefined {
|
||||||
|
return keyframe.ease ?? animation.keyframes?.easeEach ?? animation.ease;
|
||||||
|
}
|
||||||
|
|
||||||
function groupKeyframes(
|
function groupKeyframes(
|
||||||
animations: readonly GsapAnimation[],
|
animations: readonly GsapAnimation[],
|
||||||
group: PropertyGroupName,
|
group: PropertyGroupName,
|
||||||
@@ -79,6 +87,7 @@ function groupKeyframes(
|
|||||||
tweenPercentage: keyframe.percentage,
|
tweenPercentage: keyframe.percentage,
|
||||||
propertyGroup: group,
|
propertyGroup: group,
|
||||||
animationId: animation.id,
|
animationId: animation.id,
|
||||||
|
ease: keyframeEase(keyframe, animation),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,15 +125,33 @@ export function TimelinePropertyLanes({
|
|||||||
onMoveKeyframe,
|
onMoveKeyframe,
|
||||||
suppressClickRef,
|
suppressClickRef,
|
||||||
}: TimelinePropertyLanesProps) {
|
}: TimelinePropertyLanesProps) {
|
||||||
if (clipWidthPx < 20 || clipDuration <= 0) return null;
|
// Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and
|
||||||
const lanes = getTimelinePropertyLanes(animations, clipStart, clipDuration);
|
// a fresh keyframesData literal per lane) on every render would re-render every
|
||||||
|
// diamond in every expanded clip on each playhead tick.
|
||||||
|
const lanes = useMemo(
|
||||||
|
() =>
|
||||||
|
clipWidthPx < 20 || clipDuration <= 0
|
||||||
|
? []
|
||||||
|
: getTimelinePropertyLanes(animations, clipStart, clipDuration),
|
||||||
|
[animations, clipStart, clipDuration, clipWidthPx],
|
||||||
|
);
|
||||||
|
const laneData = useMemo(
|
||||||
|
() =>
|
||||||
|
lanes.map((lane) => ({
|
||||||
|
...lane,
|
||||||
|
keyframesData: { format: "percentage" as const, keyframes: lane.keyframes },
|
||||||
|
})),
|
||||||
|
[lanes],
|
||||||
|
);
|
||||||
|
|
||||||
if (lanes.length === 0) return null;
|
if (laneData.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{lanes.map(({ group, animations: groupAnimations, keyframes }, laneIndex) => (
|
{laneData.map(({ group, keyframesData }, laneIndex) => (
|
||||||
<div
|
<div
|
||||||
key={group}
|
key={group}
|
||||||
|
role="group"
|
||||||
|
aria-label={`${group} keyframes`}
|
||||||
data-property-group={group}
|
data-property-group={group}
|
||||||
data-timeline-property-lane=""
|
data-timeline-property-lane=""
|
||||||
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
||||||
@@ -137,10 +164,7 @@ export function TimelinePropertyLanes({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TimelineDiamondLane
|
<TimelineDiamondLane
|
||||||
keyframesData={{ format: "percentage", keyframes }}
|
keyframesData={keyframesData}
|
||||||
globalEase={
|
|
||||||
groupAnimations[0]?.keyframes?.easeEach ?? groupAnimations[0]?.ease ?? "none"
|
|
||||||
}
|
|
||||||
clipWidthPx={clipWidthPx}
|
clipWidthPx={clipWidthPx}
|
||||||
clipHeightPx={LANE_H}
|
clipHeightPx={LANE_H}
|
||||||
accentColor={accentColor}
|
accentColor={accentColor}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function animation(
|
|||||||
keyframes: Array<{
|
keyframes: Array<{
|
||||||
percentage: number;
|
percentage: number;
|
||||||
properties: Record<string, number | string>;
|
properties: Record<string, number | string>;
|
||||||
|
ease?: string;
|
||||||
}>,
|
}>,
|
||||||
): GsapAnimation {
|
): GsapAnimation {
|
||||||
return {
|
return {
|
||||||
@@ -126,7 +127,7 @@ describe("TimelineTrackHeader", () => {
|
|||||||
const onTogglePropertyGroupKeyframe = vi.fn();
|
const onTogglePropertyGroupKeyframe = vi.fn();
|
||||||
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
|
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
|
||||||
|
|
||||||
click(view.host, "Toggle Opacity keyframe");
|
click(view.host, "Add Opacity keyframe");
|
||||||
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
||||||
ELEMENT,
|
ELEMENT,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -139,7 +140,7 @@ describe("TimelineTrackHeader", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
|
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
|
||||||
click(view.host, "Toggle Opacity keyframe");
|
click(view.host, "Remove Opacity keyframe");
|
||||||
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
||||||
ELEMENT,
|
ELEMENT,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -214,13 +215,13 @@ describe("TimelineTrackHeader", () => {
|
|||||||
it("fills the toggle diamond exactly at that group's keyframe", () => {
|
it("fills the toggle diamond exactly at that group's keyframe", () => {
|
||||||
const view = renderHeader({ currentTime: 0.5 });
|
const view = renderHeader({ currentTime: 0.5 });
|
||||||
const positionToggle = view.host.querySelector<HTMLButtonElement>(
|
const positionToggle = view.host.querySelector<HTMLButtonElement>(
|
||||||
'button[aria-label="Toggle Position keyframe"]',
|
'button[aria-label="Add Position keyframe"]',
|
||||||
);
|
);
|
||||||
expect(positionToggle?.textContent).toBe("◇");
|
expect(positionToggle?.textContent).toBe("◇");
|
||||||
|
|
||||||
view.rerender({ currentTime: 1 });
|
view.rerender({ currentTime: 1 });
|
||||||
expect(
|
expect(
|
||||||
view.host.querySelector<HTMLButtonElement>('button[aria-label="Toggle Position keyframe"]')
|
view.host.querySelector<HTMLButtonElement>('button[aria-label="Remove Position keyframe"]')
|
||||||
?.textContent,
|
?.textContent,
|
||||||
).toBe("◆");
|
).toBe("◆");
|
||||||
act(() => view.root.unmount());
|
act(() => view.root.unmount());
|
||||||
@@ -241,6 +242,36 @@ describe("TimelineTrackHeader", () => {
|
|||||||
act(() => view.root.unmount());
|
act(() => view.root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("samples mid-segment values along the segment's ease, not linearly", () => {
|
||||||
|
// GSAP hangs a segment's ease on the keyframe it arrives at, so 0% -> 50%
|
||||||
|
// runs power2.in. Half way through that segment power2.in(0.5) = 0.125, so
|
||||||
|
// the readout is 12.5/6.25 and NOT the linear 50/25.
|
||||||
|
const eased = animation("eased-position", "position", [
|
||||||
|
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||||
|
{ percentage: 50, properties: { x: 100, y: 50 }, ease: "power2.in" },
|
||||||
|
{ percentage: 100, properties: { x: 200, y: 100 }, ease: "power2.in" },
|
||||||
|
]);
|
||||||
|
const onTogglePropertyGroupKeyframe = vi.fn();
|
||||||
|
const view = renderHeader({
|
||||||
|
animations: [eased],
|
||||||
|
currentTime: 0.5,
|
||||||
|
onTogglePropertyGroupKeyframe,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
|
||||||
|
"12.5, 6.25",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same sampled value is what an added keyframe gets stamped with, so a
|
||||||
|
// header insert lands on the existing curve instead of deforming it.
|
||||||
|
click(view.host, "Add Position keyframe");
|
||||||
|
expect(onTogglePropertyGroupKeyframe).toHaveBeenCalledOnce();
|
||||||
|
expect(onTogglePropertyGroupKeyframe.mock.calls[0][1]).toMatchObject({
|
||||||
|
properties: { x: 12.5, y: 6.25 },
|
||||||
|
});
|
||||||
|
act(() => view.root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
it("disables the previous chevron at or before the group's first keyframe", () => {
|
it("disables the previous chevron at or before the group's first keyframe", () => {
|
||||||
const view = renderHeader({ currentTime: 0 });
|
const view = renderHeader({ currentTime: 0 });
|
||||||
const prevAt0 = view.host.querySelector<HTMLButtonElement>(
|
const prevAt0 = view.host.querySelector<HTMLButtonElement>(
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ function PropertyGroupNavigation({
|
|||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
// The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
|
||||||
|
// target is met with a centered transparent ::before overlay instead of a
|
||||||
|
// bigger box; focus-visible matches every other control in this header.
|
||||||
|
const CHEVRON_BUTTON_CLASS =
|
||||||
|
"relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
|
||||||
|
"focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
|
||||||
|
"before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
|
||||||
|
"before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
|
||||||
const seekTo = (keyframe: { percentage: number } | null) => {
|
const seekTo = (keyframe: { percentage: number } | null) => {
|
||||||
if (keyframe) {
|
if (keyframe) {
|
||||||
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
|
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
|
||||||
@@ -140,7 +148,7 @@ function PropertyGroupNavigation({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`Previous ${label} keyframe`}
|
aria-label={`Previous ${label} keyframe`}
|
||||||
disabled={!navigation.prevKeyframe}
|
disabled={!navigation.prevKeyframe}
|
||||||
className="h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15"
|
className={CHEVRON_BUTTON_CLASS}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
seekTo(navigation.prevKeyframe);
|
seekTo(navigation.prevKeyframe);
|
||||||
@@ -153,7 +161,7 @@ function PropertyGroupNavigation({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`Next ${label} keyframe`}
|
aria-label={`Next ${label} keyframe`}
|
||||||
disabled={!navigation.nextKeyframe}
|
disabled={!navigation.nextKeyframe}
|
||||||
className="h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15"
|
className={CHEVRON_BUTTON_CLASS}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
seekTo(navigation.nextKeyframe);
|
seekTo(navigation.nextKeyframe);
|
||||||
@@ -242,7 +250,8 @@ function PropertyGroupHeaderRow({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Toggle ${label} keyframe`}
|
aria-pressed={!!navigation.currentKeyframe}
|
||||||
|
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||||
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||||
className="flex h-5 w-4 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
className="flex h-5 w-4 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|||||||
@@ -77,12 +77,24 @@ function validRowHeight(height: number | undefined): number {
|
|||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoized by the rowHeights array identity: a marquee/drag pointer tick calls
|
||||||
|
* getTimelineRowTop once per clip, and each call would otherwise rebuild the
|
||||||
|
* whole cumulative array (O(clips x rows) allocations per tick). rowHeights is
|
||||||
|
* itself memoized upstream (useTimelineTrackLayout), so the identity is stable
|
||||||
|
* for the life of a gesture. Callers must treat the result as read-only.
|
||||||
|
*/
|
||||||
|
const rowOffsetsCache = new WeakMap<readonly number[], number[]>();
|
||||||
|
|
||||||
/** Cumulative top offsets, including the final bottom boundary. */
|
/** Cumulative top offsets, including the final bottom boundary. */
|
||||||
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
|
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
|
||||||
|
const cached = rowOffsetsCache.get(rowHeights);
|
||||||
|
if (cached) return cached;
|
||||||
const offsets = [0];
|
const offsets = [0];
|
||||||
for (const height of rowHeights) {
|
for (const height of rowHeights) {
|
||||||
offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
|
offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
|
||||||
}
|
}
|
||||||
|
rowOffsetsCache.set(rowHeights, offsets);
|
||||||
return offsets;
|
return offsets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* reads to a human. Kept apart from the header's JSX so a formatting change and
|
* reads to a human. Kept apart from the header's JSX so a formatting change and
|
||||||
* a layout change never touch the same file.
|
* a layout change never touch the same file.
|
||||||
*/
|
*/
|
||||||
|
import gsap from "gsap";
|
||||||
import {
|
import {
|
||||||
classifyPropertyGroup,
|
classifyPropertyGroup,
|
||||||
type GsapAnimation,
|
type GsapAnimation,
|
||||||
@@ -16,31 +17,59 @@ function roundValue(value: number): string {
|
|||||||
return String(Math.round(value * 100) / 100);
|
return String(Math.round(value * 100) / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GSAP applies a keyframe's `ease` to the segment ARRIVING at it, so the curve
|
||||||
|
* between two keyframes is named by the later one (with the tween-level
|
||||||
|
* `easeEach`/`ease` as the fallback). Sampling linearly would report a value the
|
||||||
|
* element never has at that time, and stamp that wrong value onto a keyframe
|
||||||
|
* added from the track header. Unknown ease names parse to undefined -> linear. */
|
||||||
|
function easedProgress(progress: number, animation: GsapAnimation, ease?: string): number {
|
||||||
|
const resolved = ease ?? animation.keyframes?.easeEach ?? animation.ease;
|
||||||
|
if (!resolved || resolved === "none") return progress;
|
||||||
|
return gsap.parseEase(resolved)?.(progress) ?? progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropertyStop {
|
||||||
|
percentage: number;
|
||||||
|
value: number | string;
|
||||||
|
ease?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyStops(animation: GsapAnimation, property: string): PropertyStop[] {
|
||||||
|
return (animation.keyframes?.keyframes ?? [])
|
||||||
|
.filter((keyframe) => property in keyframe.properties)
|
||||||
|
.map((keyframe) => ({
|
||||||
|
percentage: keyframe.percentage,
|
||||||
|
value: keyframe.properties[property],
|
||||||
|
ease: keyframe.ease,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pair only interpolates when both ends are numeric and actually span time;
|
||||||
|
* string values (colors, keywords) and zero-width pairs hold the earlier value.
|
||||||
|
* Returns the numeric ends so the caller needs no cast to use them. */
|
||||||
|
function interpolableEnds(
|
||||||
|
before: PropertyStop,
|
||||||
|
after: PropertyStop,
|
||||||
|
): { from: number; to: number } | null {
|
||||||
|
if (typeof before.value !== "number" || typeof after.value !== "number") return null;
|
||||||
|
if (before.percentage === after.percentage) return null;
|
||||||
|
return { from: before.value, to: after.value };
|
||||||
|
}
|
||||||
|
|
||||||
function propertyValueAt(
|
function propertyValueAt(
|
||||||
animation: GsapAnimation,
|
animation: GsapAnimation,
|
||||||
property: string,
|
property: string,
|
||||||
tweenPercentage: number,
|
tweenPercentage: number,
|
||||||
): number | string | undefined {
|
): number | string | undefined {
|
||||||
const keyframes = animation.keyframes?.keyframes ?? [];
|
const stops = propertyStops(animation, property);
|
||||||
const values = keyframes
|
const before = stops.filter((stop) => stop.percentage <= tweenPercentage).at(-1);
|
||||||
.filter((keyframe) => property in keyframe.properties)
|
const after = stops.find((stop) => stop.percentage >= tweenPercentage);
|
||||||
.map((keyframe) => ({
|
|
||||||
percentage: keyframe.percentage,
|
|
||||||
value: keyframe.properties[property],
|
|
||||||
}));
|
|
||||||
const before = values.filter((value) => value.percentage <= tweenPercentage).at(-1);
|
|
||||||
const after = values.find((value) => value.percentage >= tweenPercentage);
|
|
||||||
if (!before) return after?.value;
|
if (!before) return after?.value;
|
||||||
if (!after) return before.value;
|
if (!after) return before.value;
|
||||||
if (
|
const ends = interpolableEnds(before, after);
|
||||||
typeof before.value !== "number" ||
|
if (!ends) return before.value;
|
||||||
typeof after.value !== "number" ||
|
|
||||||
before.percentage === after.percentage
|
|
||||||
) {
|
|
||||||
return before.value;
|
|
||||||
}
|
|
||||||
const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage);
|
const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage);
|
||||||
return before.value + (after.value - before.value) * progress;
|
return ends.from + (ends.to - ends.from) * easedProgress(progress, animation, after.ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every property of `group` this animation touches, sampled at `tweenPercentage`. */
|
/** Every property of `group` this animation touches, sampled at `tweenPercentage`. */
|
||||||
|
|||||||
Reference in New Issue
Block a user