summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/animation/keyframes.ts
diff options
context:
space:
mode:
authorMaze <mazewinther@gmail.com>2026-04-06 01:13:14 +0200
committerMaze <mazewinther@gmail.com>2026-04-06 01:13:14 +0200
commitdedb13546a16235e7d6649961d0c9b235f62aa34 (patch)
tree08b6e14bf6b28b4c8dc8bc9f849246c75f921593 /apps/web/src/lib/animation/keyframes.ts
parent099746633183cd061fc89a63aeb9fcfb909f6db2 (diff)
downloadopencut-dedb13546a16235e7d6649961d0c9b235f62aa34.tar.gz
feat: graphs popover
Diffstat (limited to 'apps/web/src/lib/animation/keyframes.ts')
-rw-r--r--apps/web/src/lib/animation/keyframes.ts141
1 files changed, 128 insertions, 13 deletions
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index 8c17f8a6..7eaa5ed6 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -11,6 +11,7 @@ import type {
ElementAnimations,
ScalarAnimationChannel,
ScalarAnimationKey,
+ ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/lib/animation/types";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
@@ -209,7 +210,7 @@ function getBinding({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
}): AnimationBindingInstance | undefined {
return animations?.bindings[propertyPath];
}
@@ -224,6 +225,16 @@ function getChannelById({
return animations?.channels[channelId];
}
+function getBindingComponent({
+ binding,
+ componentKey,
+}: {
+ binding: AnimationBindingInstance;
+ componentKey: string;
+}) {
+ return binding.components.find((component) => component.key === componentKey) ?? null;
+}
+
function getTargetKeyMetadata({
channel,
time,
@@ -402,7 +413,7 @@ export function getChannel({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
}): AnimationChannel | undefined {
const binding = getBinding({ animations, propertyPath });
const primaryChannelId =
@@ -651,7 +662,7 @@ export function setChannel({
channel,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
channel: AnimationChannel | undefined;
}): ElementAnimations | undefined {
const binding = getBinding({ animations, propertyPath });
@@ -659,29 +670,66 @@ export function setChannel({
return animations;
}
+ if (binding.components.length !== 1) {
+ throw new Error(
+ `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
+ );
+ }
+
const primaryComponent = getPrimaryComponent({ binding });
if (!primaryComponent) {
return animations;
}
+ return setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey: primaryComponent.key,
+ channel,
+ });
+}
+
+export function setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ channel,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ channel: AnimationChannel | undefined;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const component = getBindingComponent({
+ binding,
+ componentKey,
+ });
+ if (!component) {
+ return animations;
+ }
+
const nextAnimations = cloneAnimationsState({ animations });
if (!channel || !hasChannelKeys({ channel })) {
- for (const component of binding.components) {
- delete nextAnimations.channels[component.channelId];
+ delete nextAnimations.channels[component.channelId];
+ const hasRemainingKeys = binding.components.some((candidate) =>
+ hasChannelKeys({
+ channel: nextAnimations.channels[candidate.channelId],
+ }),
+ );
+ if (!hasRemainingKeys) {
+ delete nextAnimations.bindings[propertyPath];
}
- delete nextAnimations.bindings[propertyPath];
return toAnimation({
animations: nextAnimations,
});
}
- if (binding.components.length !== 1) {
- throw new Error(
- `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
- );
- }
-
- nextAnimations.channels[primaryComponent.channelId] = normalizeChannel({
+ nextAnimations.channels[component.channelId] = normalizeChannel({
channel,
});
return toAnimation({
@@ -689,6 +737,73 @@ export function setChannel({
});
}
+export function updateScalarKeyframeCurve({
+ animations,
+ propertyPath,
+ componentKey,
+ keyframeId,
+ patch,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const component = getBindingComponent({
+ binding,
+ componentKey,
+ });
+ if (!component) {
+ return animations;
+ }
+
+ const channel = getChannelById({
+ animations,
+ channelId: component.channelId,
+ });
+ if (channel?.kind !== "scalar") {
+ return animations;
+ }
+
+ const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId);
+ if (keyframeIndex < 0) {
+ return animations;
+ }
+
+ const nextKeys = [...channel.keys];
+ const currentKey = nextKeys[keyframeIndex];
+ nextKeys[keyframeIndex] = {
+ ...currentKey,
+ leftHandle:
+ patch.leftHandle === undefined
+ ? currentKey.leftHandle
+ : patch.leftHandle ?? undefined,
+ rightHandle:
+ patch.rightHandle === undefined
+ ? currentKey.rightHandle
+ : patch.rightHandle ?? undefined,
+ segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext,
+ tangentMode: patch.tangentMode ?? currentKey.tangentMode,
+ };
+
+ return setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ channel: {
+ kind: "scalar",
+ keys: nextKeys,
+ extrapolation: channel.extrapolation,
+ },
+ });
+}
+
export function cloneAnimations({
animations,
shouldRegenerateKeyframeIds = false,