feat: position animated independently per axis
Made-with: Cursor
This commit is contained in:
parent
8230faf082
commit
108d3f4eca
|
|
@ -1,190 +0,0 @@
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
|
||||||
import {
|
|
||||||
getKeyframeAtTime,
|
|
||||||
hasKeyframesForPath,
|
|
||||||
upsertElementKeyframe,
|
|
||||||
} from "@/lib/animation";
|
|
||||||
import type {
|
|
||||||
AnimationPropertyPath,
|
|
||||||
ElementAnimations,
|
|
||||||
VectorValue,
|
|
||||||
} from "@/lib/animation/types";
|
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
|
||||||
import { snapToStep } from "@/utils/math";
|
|
||||||
import { usePropertyDraft } from "./use-property-draft";
|
|
||||||
|
|
||||||
export function useKeyframedVectorProperty({
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
localTime,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
resolvedValue,
|
|
||||||
displayX,
|
|
||||||
displayY,
|
|
||||||
parseComponent,
|
|
||||||
step,
|
|
||||||
buildBaseUpdates,
|
|
||||||
}: {
|
|
||||||
trackId: string;
|
|
||||||
elementId: string;
|
|
||||||
animations: ElementAnimations | undefined;
|
|
||||||
propertyPath: AnimationPropertyPath;
|
|
||||||
localTime: number;
|
|
||||||
isPlayheadWithinElementRange: boolean;
|
|
||||||
resolvedValue: VectorValue;
|
|
||||||
displayX: string;
|
|
||||||
displayY: string;
|
|
||||||
parseComponent: (input: string) => number | null;
|
|
||||||
step?: number;
|
|
||||||
buildBaseUpdates: ({
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
value: VectorValue;
|
|
||||||
}) => Partial<TimelineElement>;
|
|
||||||
}) {
|
|
||||||
const editor = useEditor();
|
|
||||||
const snapComponentValue = (value: number) =>
|
|
||||||
step != null ? snapToStep({ value, step }) : value;
|
|
||||||
|
|
||||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
});
|
|
||||||
const keyframeAtTime = isPlayheadWithinElementRange
|
|
||||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
|
||||||
: null;
|
|
||||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
|
||||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
|
||||||
const shouldUseAnimatedChannel =
|
|
||||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
|
||||||
|
|
||||||
const previewVector = ({ value }: { value: VectorValue }) => {
|
|
||||||
const nextValue = {
|
|
||||||
x: snapComponentValue(value.x),
|
|
||||||
y: snapComponentValue(value.y),
|
|
||||||
};
|
|
||||||
if (shouldUseAnimatedChannel) {
|
|
||||||
editor.timeline.previewElements({
|
|
||||||
updates: [
|
|
||||||
{
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
updates: {
|
|
||||||
animations: upsertElementKeyframe({
|
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
time: localTime,
|
|
||||||
value: nextValue,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.timeline.previewElements({
|
|
||||||
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const x = usePropertyDraft({
|
|
||||||
displayValue: displayX,
|
|
||||||
parse: (input) => {
|
|
||||||
const parsedValue = parseComponent(input);
|
|
||||||
return parsedValue === null ? null : snapComponentValue(parsedValue);
|
|
||||||
},
|
|
||||||
onPreview: (xVal) =>
|
|
||||||
previewVector({ value: { x: xVal, y: resolvedValue.y } }),
|
|
||||||
onCommit: () => editor.timeline.commitPreview(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const y = usePropertyDraft({
|
|
||||||
displayValue: displayY,
|
|
||||||
parse: (input) => {
|
|
||||||
const parsedValue = parseComponent(input);
|
|
||||||
return parsedValue === null ? null : snapComponentValue(parsedValue);
|
|
||||||
},
|
|
||||||
onPreview: (yVal) =>
|
|
||||||
previewVector({ value: { x: resolvedValue.x, y: yVal } }),
|
|
||||||
onCommit: () => editor.timeline.commitPreview(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleKeyframe = () => {
|
|
||||||
if (!isPlayheadWithinElementRange) return;
|
|
||||||
|
|
||||||
if (keyframeIdAtTime) {
|
|
||||||
editor.timeline.removeKeyframes({
|
|
||||||
keyframes: [
|
|
||||||
{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.timeline.upsertKeyframes({
|
|
||||||
keyframes: [
|
|
||||||
{
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
propertyPath,
|
|
||||||
time: localTime,
|
|
||||||
value: resolvedValue,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const commitX = ({ value }: { value: number }) => {
|
|
||||||
const vector: VectorValue = {
|
|
||||||
x: snapComponentValue(value),
|
|
||||||
y: snapComponentValue(resolvedValue.y),
|
|
||||||
};
|
|
||||||
if (shouldUseAnimatedChannel) {
|
|
||||||
editor.timeline.upsertKeyframes({
|
|
||||||
keyframes: [
|
|
||||||
{ trackId, elementId, propertyPath, time: localTime, value: vector },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.timeline.updateElements({
|
|
||||||
updates: [
|
|
||||||
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const commitY = ({ value }: { value: number }) => {
|
|
||||||
const vector: VectorValue = {
|
|
||||||
x: snapComponentValue(resolvedValue.x),
|
|
||||||
y: snapComponentValue(value),
|
|
||||||
};
|
|
||||||
if (shouldUseAnimatedChannel) {
|
|
||||||
editor.timeline.upsertKeyframes({
|
|
||||||
keyframes: [
|
|
||||||
{ trackId, elementId, propertyPath, time: localTime, value: vector },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.timeline.updateElements({
|
|
||||||
updates: [
|
|
||||||
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
hasAnimatedKeyframes,
|
|
||||||
isKeyframedAtTime,
|
|
||||||
keyframeIdAtTime,
|
|
||||||
toggleKeyframe,
|
|
||||||
commitX,
|
|
||||||
commitY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,430 +1,462 @@
|
||||||
import { NumberField } from "@/components/ui/number-field";
|
import { NumberField } from "@/components/ui/number-field";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||||
import type { VisualElement } from "@/lib/timeline";
|
import type { VisualElement } from "@/lib/timeline";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
SectionField,
|
SectionField,
|
||||||
SectionFields,
|
SectionFields,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
ArrowExpandIcon,
|
ArrowExpandIcon,
|
||||||
Link05Icon,
|
Link05Icon,
|
||||||
RotateClockwiseIcon,
|
RotateClockwiseIcon,
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import {
|
import {
|
||||||
getGroupKeyframesAtTime,
|
getGroupKeyframesAtTime,
|
||||||
hasGroupKeyframeAtTime,
|
hasGroupKeyframeAtTime,
|
||||||
resolveTransformAtTime,
|
resolveTransformAtTime,
|
||||||
} from "@/lib/animation";
|
} from "@/lib/animation";
|
||||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||||
import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
|
import { usePropertiesStore } from "../stores/properties-store";
|
||||||
import { usePropertiesStore } from "../stores/properties-store";
|
|
||||||
|
export function parseNumericInput({ input }: { input: string }): number | null {
|
||||||
export function parseNumericInput({ input }: { input: string }): number | null {
|
const parsed = parseFloat(input);
|
||||||
const parsed = parseFloat(input);
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
}
|
||||||
}
|
|
||||||
|
export function isPropertyAtDefault({
|
||||||
export function isPropertyAtDefault({
|
hasAnimatedKeyframes,
|
||||||
hasAnimatedKeyframes,
|
isPlayheadWithinElementRange,
|
||||||
isPlayheadWithinElementRange,
|
resolvedValue,
|
||||||
resolvedValue,
|
staticValue,
|
||||||
staticValue,
|
defaultValue,
|
||||||
defaultValue,
|
}: {
|
||||||
}: {
|
hasAnimatedKeyframes: boolean;
|
||||||
hasAnimatedKeyframes: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
isPlayheadWithinElementRange: boolean;
|
resolvedValue: number;
|
||||||
resolvedValue: number;
|
staticValue: number;
|
||||||
staticValue: number;
|
defaultValue: number;
|
||||||
defaultValue: number;
|
}): boolean {
|
||||||
}): boolean {
|
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
|
||||||
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
|
return isNearlyEqual({
|
||||||
return isNearlyEqual({
|
leftValue: resolvedValue,
|
||||||
leftValue: resolvedValue,
|
rightValue: defaultValue,
|
||||||
rightValue: defaultValue,
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
return staticValue === defaultValue;
|
||||||
return staticValue === defaultValue;
|
}
|
||||||
}
|
|
||||||
|
export function TransformTab({
|
||||||
export function TransformTab({
|
element,
|
||||||
element,
|
trackId,
|
||||||
trackId,
|
}: {
|
||||||
}: {
|
element: VisualElement;
|
||||||
element: VisualElement;
|
trackId: string;
|
||||||
trackId: string;
|
}) {
|
||||||
}) {
|
const editor = useEditor();
|
||||||
const editor = useEditor();
|
const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
|
||||||
const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
|
const setTransformScaleLocked = usePropertiesStore(
|
||||||
const setTransformScaleLocked = usePropertiesStore(
|
(s) => s.setTransformScaleLocked,
|
||||||
(s) => s.setTransformScaleLocked,
|
);
|
||||||
);
|
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
startTime: element.startTime,
|
||||||
startTime: element.startTime,
|
duration: element.duration,
|
||||||
duration: element.duration,
|
});
|
||||||
});
|
const resolvedTransform = resolveTransformAtTime({
|
||||||
const resolvedTransform = resolveTransformAtTime({
|
baseTransform: element.transform,
|
||||||
baseTransform: element.transform,
|
animations: element.animations,
|
||||||
animations: element.animations,
|
localTime,
|
||||||
localTime,
|
});
|
||||||
});
|
|
||||||
|
const positionX = useKeyframedNumberProperty({
|
||||||
const position = useKeyframedVectorProperty({
|
trackId,
|
||||||
trackId,
|
elementId: element.id,
|
||||||
elementId: element.id,
|
animations: element.animations,
|
||||||
animations: element.animations,
|
propertyPath: "transform.positionX",
|
||||||
propertyPath: "transform.position",
|
localTime,
|
||||||
localTime,
|
isPlayheadWithinElementRange,
|
||||||
isPlayheadWithinElementRange,
|
displayValue: Math.round(resolvedTransform.position.x).toString(),
|
||||||
resolvedValue: resolvedTransform.position,
|
parse: (input) => parseNumericInput({ input }),
|
||||||
displayX: Math.round(resolvedTransform.position.x).toString(),
|
valueAtPlayhead: resolvedTransform.position.x,
|
||||||
displayY: Math.round(resolvedTransform.position.y).toString(),
|
step: 1,
|
||||||
parseComponent: (input) => parseNumericInput({ input }),
|
buildBaseUpdates: ({ value }) => ({
|
||||||
step: 1,
|
transform: {
|
||||||
buildBaseUpdates: ({ value }) => ({
|
...element.transform,
|
||||||
transform: { ...element.transform, position: value },
|
position: { ...element.transform.position, x: value },
|
||||||
}),
|
},
|
||||||
});
|
}),
|
||||||
|
});
|
||||||
const parseScale = (input: string) => {
|
|
||||||
const parsed = parseNumericInput({ input });
|
const positionY = useKeyframedNumberProperty({
|
||||||
if (parsed === null) return null;
|
trackId,
|
||||||
return parsed / 100;
|
elementId: element.id,
|
||||||
};
|
animations: element.animations,
|
||||||
|
propertyPath: "transform.positionY",
|
||||||
const scaleX = useKeyframedNumberProperty({
|
localTime,
|
||||||
trackId,
|
isPlayheadWithinElementRange,
|
||||||
elementId: element.id,
|
displayValue: Math.round(resolvedTransform.position.y).toString(),
|
||||||
animations: element.animations,
|
parse: (input) => parseNumericInput({ input }),
|
||||||
propertyPath: "transform.scaleX",
|
valueAtPlayhead: resolvedTransform.position.y,
|
||||||
localTime,
|
step: 1,
|
||||||
isPlayheadWithinElementRange,
|
buildBaseUpdates: ({ value }) => ({
|
||||||
displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
|
transform: {
|
||||||
parse: parseScale,
|
...element.transform,
|
||||||
valueAtPlayhead: resolvedTransform.scaleX,
|
position: { ...element.transform.position, y: value },
|
||||||
step: 0.01,
|
},
|
||||||
buildBaseUpdates: ({ value }) => ({
|
}),
|
||||||
transform: {
|
});
|
||||||
...element.transform,
|
|
||||||
scaleX: value,
|
const parseScale = (input: string) => {
|
||||||
...(isScaleLocked ? { scaleY: value } : {}),
|
const parsed = parseNumericInput({ input });
|
||||||
},
|
if (parsed === null) return null;
|
||||||
}),
|
return parsed / 100;
|
||||||
buildAdditionalKeyframes: isScaleLocked
|
};
|
||||||
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
|
|
||||||
: undefined,
|
const scaleX = useKeyframedNumberProperty({
|
||||||
});
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
const scaleY = useKeyframedNumberProperty({
|
animations: element.animations,
|
||||||
trackId,
|
propertyPath: "transform.scaleX",
|
||||||
elementId: element.id,
|
localTime,
|
||||||
animations: element.animations,
|
isPlayheadWithinElementRange,
|
||||||
propertyPath: "transform.scaleY",
|
displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
|
||||||
localTime,
|
parse: parseScale,
|
||||||
isPlayheadWithinElementRange,
|
valueAtPlayhead: resolvedTransform.scaleX,
|
||||||
displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
|
step: 0.01,
|
||||||
parse: parseScale,
|
buildBaseUpdates: ({ value }) => ({
|
||||||
valueAtPlayhead: resolvedTransform.scaleY,
|
transform: {
|
||||||
step: 0.01,
|
...element.transform,
|
||||||
buildBaseUpdates: ({ value }) => ({
|
scaleX: value,
|
||||||
transform: {
|
...(isScaleLocked ? { scaleY: value } : {}),
|
||||||
...element.transform,
|
},
|
||||||
scaleY: value,
|
}),
|
||||||
...(isScaleLocked ? { scaleX: value } : {}),
|
buildAdditionalKeyframes: isScaleLocked
|
||||||
},
|
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
|
||||||
}),
|
: undefined,
|
||||||
buildAdditionalKeyframes: isScaleLocked
|
});
|
||||||
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
|
|
||||||
: undefined,
|
const scaleY = useKeyframedNumberProperty({
|
||||||
});
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
const scaleFieldPropsX = {
|
animations: element.animations,
|
||||||
value: scaleX.displayValue,
|
propertyPath: "transform.scaleY",
|
||||||
onFocus: scaleX.onFocus,
|
localTime,
|
||||||
onChange: scaleX.onChange,
|
isPlayheadWithinElementRange,
|
||||||
onBlur: scaleX.onBlur,
|
displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
|
||||||
dragSensitivity: "slow" as const,
|
parse: parseScale,
|
||||||
onScrub: scaleX.scrubTo,
|
valueAtPlayhead: resolvedTransform.scaleY,
|
||||||
onScrubEnd: scaleX.commitScrub,
|
step: 0.01,
|
||||||
onReset: () =>
|
buildBaseUpdates: ({ value }) => ({
|
||||||
scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
|
transform: {
|
||||||
isDefault: isPropertyAtDefault({
|
...element.transform,
|
||||||
hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
|
scaleY: value,
|
||||||
isPlayheadWithinElementRange,
|
...(isScaleLocked ? { scaleX: value } : {}),
|
||||||
resolvedValue: resolvedTransform.scaleX,
|
},
|
||||||
staticValue: element.transform.scaleX,
|
}),
|
||||||
defaultValue: DEFAULTS.element.transform.scaleX,
|
buildAdditionalKeyframes: isScaleLocked
|
||||||
}),
|
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
|
||||||
};
|
: undefined,
|
||||||
|
});
|
||||||
const scaleFieldPropsY = {
|
|
||||||
value: scaleY.displayValue,
|
const scaleFieldPropsX = {
|
||||||
onFocus: scaleY.onFocus,
|
value: scaleX.displayValue,
|
||||||
onChange: scaleY.onChange,
|
onFocus: scaleX.onFocus,
|
||||||
onBlur: scaleY.onBlur,
|
onChange: scaleX.onChange,
|
||||||
dragSensitivity: "slow" as const,
|
onBlur: scaleX.onBlur,
|
||||||
onScrub: scaleY.scrubTo,
|
dragSensitivity: "slow" as const,
|
||||||
onScrubEnd: scaleY.commitScrub,
|
onScrub: scaleX.scrubTo,
|
||||||
onReset: () =>
|
onScrubEnd: scaleX.commitScrub,
|
||||||
scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
|
onReset: () =>
|
||||||
isDefault: isPropertyAtDefault({
|
scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
|
||||||
hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
|
isDefault: isPropertyAtDefault({
|
||||||
isPlayheadWithinElementRange,
|
hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
|
||||||
resolvedValue: resolvedTransform.scaleY,
|
isPlayheadWithinElementRange,
|
||||||
staticValue: element.transform.scaleY,
|
resolvedValue: resolvedTransform.scaleX,
|
||||||
defaultValue: DEFAULTS.element.transform.scaleY,
|
staticValue: element.transform.scaleX,
|
||||||
}),
|
defaultValue: DEFAULTS.element.transform.scaleX,
|
||||||
};
|
}),
|
||||||
|
};
|
||||||
const rotation = useKeyframedNumberProperty({
|
|
||||||
trackId,
|
const scaleFieldPropsY = {
|
||||||
elementId: element.id,
|
value: scaleY.displayValue,
|
||||||
animations: element.animations,
|
onFocus: scaleY.onFocus,
|
||||||
propertyPath: "transform.rotate",
|
onChange: scaleY.onChange,
|
||||||
localTime,
|
onBlur: scaleY.onBlur,
|
||||||
isPlayheadWithinElementRange,
|
dragSensitivity: "slow" as const,
|
||||||
displayValue: Math.round(resolvedTransform.rotate).toString(),
|
onScrub: scaleY.scrubTo,
|
||||||
parse: (input) => {
|
onScrubEnd: scaleY.commitScrub,
|
||||||
const parsed = parseNumericInput({ input });
|
onReset: () =>
|
||||||
if (parsed === null) return null;
|
scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
|
||||||
return clamp({ value: parsed, min: -360, max: 360 });
|
isDefault: isPropertyAtDefault({
|
||||||
},
|
hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
|
||||||
valueAtPlayhead: resolvedTransform.rotate,
|
isPlayheadWithinElementRange,
|
||||||
step: 1,
|
resolvedValue: resolvedTransform.scaleY,
|
||||||
buildBaseUpdates: ({ value }) => ({
|
staticValue: element.transform.scaleY,
|
||||||
transform: {
|
defaultValue: DEFAULTS.element.transform.scaleY,
|
||||||
...element.transform,
|
}),
|
||||||
rotate: value,
|
};
|
||||||
},
|
|
||||||
}),
|
const rotation = useKeyframedNumberProperty({
|
||||||
});
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
const hasScaleKeyframe = hasGroupKeyframeAtTime({
|
animations: element.animations,
|
||||||
animations: element.animations,
|
propertyPath: "transform.rotate",
|
||||||
group: "transform.scale",
|
localTime,
|
||||||
time: localTime,
|
isPlayheadWithinElementRange,
|
||||||
});
|
displayValue: Math.round(resolvedTransform.rotate).toString(),
|
||||||
|
parse: (input) => {
|
||||||
const toggleScaleKeyframe = () => {
|
const parsed = parseNumericInput({ input });
|
||||||
if (!isPlayheadWithinElementRange) return;
|
if (parsed === null) return null;
|
||||||
const existing = getGroupKeyframesAtTime({
|
return clamp({ value: parsed, min: -360, max: 360 });
|
||||||
animations: element.animations,
|
},
|
||||||
group: "transform.scale",
|
valueAtPlayhead: resolvedTransform.rotate,
|
||||||
time: localTime,
|
step: 1,
|
||||||
});
|
buildBaseUpdates: ({ value }) => ({
|
||||||
if (existing.length > 0) {
|
transform: {
|
||||||
editor.timeline.removeKeyframes({
|
...element.transform,
|
||||||
keyframes: existing.map((ref) => ({
|
rotate: value,
|
||||||
trackId,
|
},
|
||||||
elementId: element.id,
|
}),
|
||||||
...ref,
|
});
|
||||||
})),
|
|
||||||
});
|
const hasScaleKeyframe = hasGroupKeyframeAtTime({
|
||||||
return;
|
animations: element.animations,
|
||||||
}
|
group: "transform.scale",
|
||||||
editor.timeline.upsertKeyframes({
|
time: localTime,
|
||||||
keyframes: [
|
});
|
||||||
{
|
|
||||||
trackId,
|
const toggleScaleKeyframe = () => {
|
||||||
elementId: element.id,
|
if (!isPlayheadWithinElementRange) return;
|
||||||
propertyPath: "transform.scaleX",
|
const existing = getGroupKeyframesAtTime({
|
||||||
time: localTime,
|
animations: element.animations,
|
||||||
value: resolvedTransform.scaleX,
|
group: "transform.scale",
|
||||||
},
|
time: localTime,
|
||||||
{
|
});
|
||||||
trackId,
|
if (existing.length > 0) {
|
||||||
elementId: element.id,
|
editor.timeline.removeKeyframes({
|
||||||
propertyPath: "transform.scaleY",
|
keyframes: existing.map((ref) => ({
|
||||||
time: localTime,
|
trackId,
|
||||||
value: resolvedTransform.scaleY,
|
elementId: element.id,
|
||||||
},
|
...ref,
|
||||||
],
|
})),
|
||||||
});
|
});
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
const scaleLockButton = (
|
editor.timeline.upsertKeyframes({
|
||||||
<Button
|
keyframes: [
|
||||||
type="button"
|
{
|
||||||
variant={isScaleLocked ? "secondary" : "ghost"}
|
trackId,
|
||||||
size="icon"
|
elementId: element.id,
|
||||||
aria-pressed={isScaleLocked}
|
propertyPath: "transform.scaleX",
|
||||||
onClick={() => setTransformScaleLocked(!isScaleLocked)}
|
time: localTime,
|
||||||
>
|
value: resolvedTransform.scaleX,
|
||||||
<HugeiconsIcon icon={Link05Icon} />
|
},
|
||||||
</Button>
|
{
|
||||||
);
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
return (
|
propertyPath: "transform.scaleY",
|
||||||
<Section collapsible sectionKey={`${element.id}:transform`}>
|
time: localTime,
|
||||||
<SectionHeader>
|
value: resolvedTransform.scaleY,
|
||||||
<SectionTitle>Transform</SectionTitle>
|
},
|
||||||
</SectionHeader>
|
],
|
||||||
<SectionContent>
|
});
|
||||||
<SectionFields>
|
};
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
{isScaleLocked ? (
|
const scaleLockButton = (
|
||||||
<>
|
<Button
|
||||||
<SectionField
|
type="button"
|
||||||
label="Scale"
|
variant={isScaleLocked ? "secondary" : "ghost"}
|
||||||
className="min-w-0 flex-1"
|
size="icon"
|
||||||
beforeLabel={
|
aria-pressed={isScaleLocked}
|
||||||
<KeyframeToggle
|
onClick={() => setTransformScaleLocked(!isScaleLocked)}
|
||||||
isActive={hasScaleKeyframe}
|
>
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
<HugeiconsIcon icon={Link05Icon} />
|
||||||
title="Toggle scale keyframe"
|
</Button>
|
||||||
onToggle={toggleScaleKeyframe}
|
);
|
||||||
/>
|
|
||||||
}
|
return (
|
||||||
>
|
<Section collapsible sectionKey={`${element.id}:transform`}>
|
||||||
<NumberField
|
<SectionHeader>
|
||||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
<SectionTitle>Transform</SectionTitle>
|
||||||
{...scaleFieldPropsX}
|
</SectionHeader>
|
||||||
/>
|
<SectionContent>
|
||||||
</SectionField>
|
<SectionFields>
|
||||||
{scaleLockButton}
|
<div className="flex items-end gap-2">
|
||||||
</>
|
{isScaleLocked ? (
|
||||||
) : (
|
<>
|
||||||
<>
|
<SectionField
|
||||||
<SectionField
|
label="Scale"
|
||||||
label="Width"
|
className="min-w-0 flex-1"
|
||||||
className="min-w-0 flex-1"
|
beforeLabel={
|
||||||
beforeLabel={
|
<KeyframeToggle
|
||||||
<KeyframeToggle
|
isActive={hasScaleKeyframe}
|
||||||
isActive={scaleX.isKeyframedAtTime}
|
isDisabled={!isPlayheadWithinElementRange}
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
title="Toggle scale keyframe"
|
||||||
title="Toggle width scale keyframe"
|
onToggle={toggleScaleKeyframe}
|
||||||
onToggle={scaleX.toggleKeyframe}
|
/>
|
||||||
/>
|
}
|
||||||
}
|
>
|
||||||
>
|
<NumberField
|
||||||
<NumberField icon="W" {...scaleFieldPropsX} />
|
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||||
</SectionField>
|
{...scaleFieldPropsX}
|
||||||
{scaleLockButton}
|
/>
|
||||||
<SectionField
|
</SectionField>
|
||||||
label="Height"
|
{scaleLockButton}
|
||||||
className="min-w-0 flex-1"
|
</>
|
||||||
beforeLabel={
|
) : (
|
||||||
<KeyframeToggle
|
<>
|
||||||
isActive={scaleY.isKeyframedAtTime}
|
<SectionField
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
label="Width"
|
||||||
title="Toggle height scale keyframe"
|
className="min-w-0 flex-1"
|
||||||
onToggle={scaleY.toggleKeyframe}
|
beforeLabel={
|
||||||
/>
|
<KeyframeToggle
|
||||||
}
|
isActive={scaleX.isKeyframedAtTime}
|
||||||
>
|
isDisabled={!isPlayheadWithinElementRange}
|
||||||
<NumberField icon="H" {...scaleFieldPropsY} />
|
title="Toggle width scale keyframe"
|
||||||
</SectionField>
|
onToggle={scaleX.toggleKeyframe}
|
||||||
</>
|
/>
|
||||||
)}
|
}
|
||||||
</div>
|
>
|
||||||
<SectionField
|
<NumberField icon="W" {...scaleFieldPropsX} />
|
||||||
label="Position"
|
</SectionField>
|
||||||
beforeLabel={
|
{scaleLockButton}
|
||||||
<KeyframeToggle
|
<SectionField
|
||||||
isActive={position.isKeyframedAtTime}
|
label="Height"
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
className="min-w-0 flex-1"
|
||||||
title="Toggle position keyframe"
|
beforeLabel={
|
||||||
onToggle={position.toggleKeyframe}
|
<KeyframeToggle
|
||||||
/>
|
isActive={scaleY.isKeyframedAtTime}
|
||||||
}
|
isDisabled={!isPlayheadWithinElementRange}
|
||||||
>
|
title="Toggle height scale keyframe"
|
||||||
<div className="flex items-center gap-2">
|
onToggle={scaleY.toggleKeyframe}
|
||||||
<NumberField
|
/>
|
||||||
icon="X"
|
}
|
||||||
className="flex-1"
|
>
|
||||||
value={position.x.displayValue}
|
<NumberField icon="H" {...scaleFieldPropsY} />
|
||||||
onFocus={position.x.onFocus}
|
</SectionField>
|
||||||
onChange={position.x.onChange}
|
</>
|
||||||
onBlur={position.x.onBlur}
|
)}
|
||||||
onScrub={position.x.scrubTo}
|
</div>
|
||||||
onScrubEnd={position.x.commitScrub}
|
<div className="flex items-end gap-2">
|
||||||
onReset={() =>
|
<SectionField
|
||||||
position.commitX({
|
label="X"
|
||||||
value: DEFAULTS.element.transform.position.x,
|
className="min-w-0 flex-1"
|
||||||
})
|
beforeLabel={
|
||||||
}
|
<KeyframeToggle
|
||||||
isDefault={isPropertyAtDefault({
|
isActive={positionX.isKeyframedAtTime}
|
||||||
hasAnimatedKeyframes: position.hasAnimatedKeyframes,
|
isDisabled={!isPlayheadWithinElementRange}
|
||||||
isPlayheadWithinElementRange,
|
title="Toggle X position keyframe"
|
||||||
resolvedValue: resolvedTransform.position.x,
|
onToggle={positionX.toggleKeyframe}
|
||||||
staticValue: element.transform.position.x,
|
/>
|
||||||
defaultValue: DEFAULTS.element.transform.position.x,
|
}
|
||||||
})}
|
>
|
||||||
/>
|
<NumberField
|
||||||
<NumberField
|
icon="X"
|
||||||
icon="Y"
|
value={positionX.displayValue}
|
||||||
className="flex-1"
|
onFocus={positionX.onFocus}
|
||||||
value={position.y.displayValue}
|
onChange={positionX.onChange}
|
||||||
onFocus={position.y.onFocus}
|
onBlur={positionX.onBlur}
|
||||||
onChange={position.y.onChange}
|
onScrub={positionX.scrubTo}
|
||||||
onBlur={position.y.onBlur}
|
onScrubEnd={positionX.commitScrub}
|
||||||
onScrub={position.y.scrubTo}
|
onReset={() =>
|
||||||
onScrubEnd={position.y.commitScrub}
|
positionX.commitValue({
|
||||||
onReset={() =>
|
value: DEFAULTS.element.transform.position.x,
|
||||||
position.commitY({
|
})
|
||||||
value: DEFAULTS.element.transform.position.y,
|
}
|
||||||
})
|
isDefault={isPropertyAtDefault({
|
||||||
}
|
hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
|
||||||
isDefault={isPropertyAtDefault({
|
isPlayheadWithinElementRange,
|
||||||
hasAnimatedKeyframes: position.hasAnimatedKeyframes,
|
resolvedValue: resolvedTransform.position.x,
|
||||||
isPlayheadWithinElementRange,
|
staticValue: element.transform.position.x,
|
||||||
resolvedValue: resolvedTransform.position.y,
|
defaultValue: DEFAULTS.element.transform.position.x,
|
||||||
staticValue: element.transform.position.y,
|
})}
|
||||||
defaultValue: DEFAULTS.element.transform.position.y,
|
/>
|
||||||
})}
|
</SectionField>
|
||||||
/>
|
<SectionField
|
||||||
</div>
|
label="Y"
|
||||||
</SectionField>
|
className="min-w-0 flex-1"
|
||||||
|
beforeLabel={
|
||||||
<SectionField
|
<KeyframeToggle
|
||||||
label="Rotation"
|
isActive={positionY.isKeyframedAtTime}
|
||||||
beforeLabel={
|
isDisabled={!isPlayheadWithinElementRange}
|
||||||
<KeyframeToggle
|
title="Toggle Y position keyframe"
|
||||||
isActive={rotation.isKeyframedAtTime}
|
onToggle={positionY.toggleKeyframe}
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
/>
|
||||||
title="Toggle rotation keyframe"
|
}
|
||||||
onToggle={rotation.toggleKeyframe}
|
>
|
||||||
/>
|
<NumberField
|
||||||
}
|
icon="Y"
|
||||||
>
|
value={positionY.displayValue}
|
||||||
<div className="flex items-center gap-2">
|
onFocus={positionY.onFocus}
|
||||||
<NumberField
|
onChange={positionY.onChange}
|
||||||
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
onBlur={positionY.onBlur}
|
||||||
className="flex-none"
|
onScrub={positionY.scrubTo}
|
||||||
value={rotation.displayValue}
|
onScrubEnd={positionY.commitScrub}
|
||||||
onFocus={rotation.onFocus}
|
onReset={() =>
|
||||||
onChange={rotation.onChange}
|
positionY.commitValue({
|
||||||
onBlur={rotation.onBlur}
|
value: DEFAULTS.element.transform.position.y,
|
||||||
dragSensitivity="slow"
|
})
|
||||||
onScrub={rotation.scrubTo}
|
}
|
||||||
onScrubEnd={rotation.commitScrub}
|
isDefault={isPropertyAtDefault({
|
||||||
onReset={() =>
|
hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
|
||||||
rotation.commitValue({
|
isPlayheadWithinElementRange,
|
||||||
value: DEFAULTS.element.transform.rotate,
|
resolvedValue: resolvedTransform.position.y,
|
||||||
})
|
staticValue: element.transform.position.y,
|
||||||
}
|
defaultValue: DEFAULTS.element.transform.position.y,
|
||||||
isDefault={isPropertyAtDefault({
|
})}
|
||||||
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
|
/>
|
||||||
isPlayheadWithinElementRange,
|
</SectionField>
|
||||||
resolvedValue: resolvedTransform.rotate,
|
</div>
|
||||||
staticValue: element.transform.rotate,
|
|
||||||
defaultValue: DEFAULTS.element.transform.rotate,
|
<SectionField
|
||||||
})}
|
label="Rotation"
|
||||||
/>
|
beforeLabel={
|
||||||
</div>
|
<KeyframeToggle
|
||||||
</SectionField>
|
isActive={rotation.isKeyframedAtTime}
|
||||||
</SectionFields>
|
isDisabled={!isPlayheadWithinElementRange}
|
||||||
</SectionContent>
|
title="Toggle rotation keyframe"
|
||||||
</Section>
|
onToggle={rotation.toggleKeyframe}
|
||||||
);
|
/>
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NumberField
|
||||||
|
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
||||||
|
className="flex-none"
|
||||||
|
value={rotation.displayValue}
|
||||||
|
onFocus={rotation.onFocus}
|
||||||
|
onChange={rotation.onChange}
|
||||||
|
onBlur={rotation.onBlur}
|
||||||
|
dragSensitivity="slow"
|
||||||
|
onScrub={rotation.scrubTo}
|
||||||
|
onScrubEnd={rotation.commitScrub}
|
||||||
|
onReset={() =>
|
||||||
|
rotation.commitValue({
|
||||||
|
value: DEFAULTS.element.transform.rotate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isDefault={isPropertyAtDefault({
|
||||||
|
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
|
||||||
|
isPlayheadWithinElementRange,
|
||||||
|
resolvedValue: resolvedTransform.rotate,
|
||||||
|
staticValue: element.transform.rotate,
|
||||||
|
defaultValue: DEFAULTS.element.transform.rotate,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionField>
|
||||||
|
</SectionFields>
|
||||||
|
</SectionContent>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,18 @@ export function isVectorValue(value: unknown): value is VectorValue {
|
||||||
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
|
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EasingMode = "independent" | "shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares how easing curves apply to a binding's components.
|
||||||
|
* "shared" means all components always use the same curve (e.g. color — you never
|
||||||
|
* want to ease R independently from G/B/A). "independent" means each component
|
||||||
|
* can have its own curve.
|
||||||
|
*/
|
||||||
|
export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
|
||||||
|
return kind === "color" ? "shared" : "independent";
|
||||||
|
}
|
||||||
|
|
||||||
export function getBindingComponentKeys({
|
export function getBindingComponentKeys({
|
||||||
kind,
|
kind,
|
||||||
}: {
|
}: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type {
|
import type {
|
||||||
|
AnimationBindingInstance,
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
ScalarAnimationChannel,
|
ScalarAnimationChannel,
|
||||||
|
|
@ -6,6 +7,11 @@ import type {
|
||||||
ScalarGraphKeyframeContext,
|
ScalarGraphKeyframeContext,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
|
|
||||||
|
export interface EditableScalarChannels {
|
||||||
|
binding: AnimationBindingInstance;
|
||||||
|
channels: ScalarGraphChannel[];
|
||||||
|
}
|
||||||
|
|
||||||
function isScalarAnimationChannel(
|
function isScalarAnimationChannel(
|
||||||
channel: ElementAnimations["channels"][string],
|
channel: ElementAnimations["channels"][string],
|
||||||
): channel is ScalarAnimationChannel {
|
): channel is ScalarAnimationChannel {
|
||||||
|
|
@ -18,13 +24,13 @@ export function getEditableScalarChannels({
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
}): ScalarGraphChannel[] {
|
}): EditableScalarChannels | null {
|
||||||
const binding = animations?.bindings[propertyPath];
|
const binding = animations?.bindings[propertyPath];
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return binding.components.flatMap((component) => {
|
const channels = binding.components.flatMap((component) => {
|
||||||
const channel = animations?.channels[component.channelId];
|
const channel = animations?.channels[component.channelId];
|
||||||
if (!isScalarAnimationChannel(channel)) {
|
if (!isScalarAnimationChannel(channel)) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -36,9 +42,11 @@ export function getEditableScalarChannels({
|
||||||
componentKey: component.key,
|
componentKey: component.key,
|
||||||
channelId: component.channelId,
|
channelId: component.channelId,
|
||||||
channel,
|
channel,
|
||||||
},
|
} satisfies ScalarGraphChannel,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { binding, channels };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEditableScalarChannel({
|
export function getEditableScalarChannel({
|
||||||
|
|
@ -50,12 +58,8 @@ export function getEditableScalarChannel({
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
componentKey: string;
|
componentKey: string;
|
||||||
}): ScalarGraphChannel | null {
|
}): ScalarGraphChannel | null {
|
||||||
return (
|
const result = getEditableScalarChannels({ animations, propertyPath });
|
||||||
getEditableScalarChannels({
|
return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null;
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
}).find((channel) => channel.componentKey === componentKey) ?? null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScalarKeyframeContext({
|
export function getScalarKeyframeContext({
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export {
|
||||||
} from "./keyframe-query";
|
} from "./keyframe-query";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
type EditableScalarChannels,
|
||||||
getEditableScalarChannel,
|
getEditableScalarChannel,
|
||||||
getEditableScalarChannels,
|
getEditableScalarChannels,
|
||||||
getScalarKeyframeContext,
|
getScalarKeyframeContext,
|
||||||
|
|
@ -90,5 +91,6 @@ export {
|
||||||
} from "./property-groups";
|
} from "./property-groups";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
isVectorValue,
|
type EasingMode,
|
||||||
|
getEasingModeForKind,
|
||||||
} from "./binding-values";
|
} from "./binding-values";
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ function createScalarKey({
|
||||||
id: string;
|
id: string;
|
||||||
time: number;
|
time: number;
|
||||||
value: number;
|
value: number;
|
||||||
interpolation: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
previousKey?: ScalarAnimationKey;
|
previousKey?: ScalarAnimationKey;
|
||||||
}): ScalarAnimationKey {
|
}): ScalarAnimationKey {
|
||||||
return {
|
return {
|
||||||
|
|
@ -183,7 +183,8 @@ function createScalarKey({
|
||||||
leftHandle: previousKey?.leftHandle,
|
leftHandle: previousKey?.leftHandle,
|
||||||
rightHandle: previousKey?.rightHandle,
|
rightHandle: previousKey?.rightHandle,
|
||||||
segmentToNext:
|
segmentToNext:
|
||||||
previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }),
|
previousKey?.segmentToNext ??
|
||||||
|
getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
|
||||||
tangentMode: previousKey?.tangentMode ?? "flat",
|
tangentMode: previousKey?.tangentMode ?? "flat",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -332,12 +333,14 @@ function upsertScalarChannelKey({
|
||||||
time,
|
time,
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation,
|
||||||
|
defaultInterpolation,
|
||||||
keyframeId,
|
keyframeId,
|
||||||
}: {
|
}: {
|
||||||
channel: ScalarAnimationChannel | undefined;
|
channel: ScalarAnimationChannel | undefined;
|
||||||
time: number;
|
time: number;
|
||||||
value: number;
|
value: number;
|
||||||
interpolation: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
|
defaultInterpolation?: AnimationInterpolation;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
}): ScalarAnimationChannel {
|
}): ScalarAnimationChannel {
|
||||||
const normalizedChannel = normalizeChannel({
|
const normalizedChannel = normalizeChannel({
|
||||||
|
|
@ -352,10 +355,13 @@ function upsertScalarChannelKey({
|
||||||
time,
|
time,
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation,
|
||||||
previousKey: {
|
previousKey:
|
||||||
...keys[existingIndex],
|
interpolation != null
|
||||||
segmentToNext: getScalarSegmentType({ interpolation }),
|
? {
|
||||||
},
|
...keys[existingIndex],
|
||||||
|
segmentToNext: getScalarSegmentType({ interpolation }),
|
||||||
|
}
|
||||||
|
: keys[existingIndex],
|
||||||
});
|
});
|
||||||
return normalizeChannel({
|
return normalizeChannel({
|
||||||
channel: {
|
channel: {
|
||||||
|
|
@ -376,10 +382,13 @@ function upsertScalarChannelKey({
|
||||||
time: keys[existingAtTimeIndex].time,
|
time: keys[existingAtTimeIndex].time,
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation,
|
||||||
previousKey: {
|
previousKey:
|
||||||
...keys[existingAtTimeIndex],
|
interpolation != null
|
||||||
segmentToNext: getScalarSegmentType({ interpolation }),
|
? {
|
||||||
},
|
...keys[existingAtTimeIndex],
|
||||||
|
segmentToNext: getScalarSegmentType({ interpolation }),
|
||||||
|
}
|
||||||
|
: keys[existingAtTimeIndex],
|
||||||
});
|
});
|
||||||
return normalizeChannel({
|
return normalizeChannel({
|
||||||
channel: {
|
channel: {
|
||||||
|
|
@ -395,7 +404,7 @@ function upsertScalarChannelKey({
|
||||||
id: keyframeId ?? generateUUID(),
|
id: keyframeId ?? generateUUID(),
|
||||||
time,
|
time,
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation: interpolation ?? defaultInterpolation,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return normalizeChannel({
|
return normalizeChannel({
|
||||||
|
|
@ -472,9 +481,13 @@ export function upsertPathKeyframe({
|
||||||
return animations;
|
return animations;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextInterpolation = getInterpolationForBinding({
|
const explicitInterpolation =
|
||||||
|
interpolation != null
|
||||||
|
? getInterpolationForBinding({ kind, interpolation })
|
||||||
|
: undefined;
|
||||||
|
const validatedDefaultInterpolation = getInterpolationForBinding({
|
||||||
kind,
|
kind,
|
||||||
interpolation: interpolation ?? defaultInterpolation,
|
interpolation: defaultInterpolation,
|
||||||
});
|
});
|
||||||
nextAnimations.bindings[propertyPath] = binding;
|
nextAnimations.bindings[propertyPath] = binding;
|
||||||
for (const component of binding.components) {
|
for (const component of binding.components) {
|
||||||
|
|
@ -503,7 +516,8 @@ export function upsertPathKeyframe({
|
||||||
channel: targetChannel,
|
channel: targetChannel,
|
||||||
time: targetKey.time,
|
time: targetKey.time,
|
||||||
value: nextValue as number,
|
value: nextValue as number,
|
||||||
interpolation: nextInterpolation,
|
interpolation: explicitInterpolation,
|
||||||
|
defaultInterpolation: validatedDefaultInterpolation,
|
||||||
keyframeId: targetKey.id,
|
keyframeId: targetKey.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -592,7 +606,7 @@ export function upsertKeyframe({
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
value,
|
value,
|
||||||
interpolation: interpolation ?? "linear",
|
interpolation,
|
||||||
keyframeId,
|
keyframeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@ import type {
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
VectorValue,
|
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
|
import { parseColorToLinearRgba } from "./binding-values";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import type { TimelineElement } from "@/lib/timeline";
|
||||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||||
import {
|
import {
|
||||||
|
|
@ -116,24 +115,38 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
AnimationPropertyDefinition
|
AnimationPropertyDefinition
|
||||||
> = {
|
> = {
|
||||||
"transform.position": {
|
"transform.positionX": createNumberPropertyDefinition({
|
||||||
kind: "vector2",
|
numericRange: { step: 1 },
|
||||||
defaultInterpolation: "linear",
|
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
isVisualElement(element) ? element.transform.position : null,
|
isVisualElement(element) ? element.transform.position.x : null,
|
||||||
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
|
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
isVisualElement(element)
|
isVisualElement(element)
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
transform: {
|
transform: {
|
||||||
...element.transform,
|
...element.transform,
|
||||||
position: value as VectorValue,
|
position: { ...element.transform.position, x: value as number },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
|
"transform.positionY": createNumberPropertyDefinition({
|
||||||
|
numericRange: { step: 1 },
|
||||||
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
|
getValue: ({ element }) =>
|
||||||
|
isVisualElement(element) ? element.transform.position.y : null,
|
||||||
|
setValue: ({ element, value }) =>
|
||||||
|
isVisualElement(element)
|
||||||
|
? {
|
||||||
|
...element,
|
||||||
|
transform: {
|
||||||
|
...element.transform,
|
||||||
|
position: { ...element.transform.position, y: value as number },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: element,
|
||||||
|
}),
|
||||||
"transform.scaleX": createNumberPropertyDefinition({
|
"transform.scaleX": createNumberPropertyDefinition({
|
||||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,20 @@ export function resolveTransformAtTime({
|
||||||
}): Transform {
|
}): Transform {
|
||||||
const safeLocalTime = Math.max(0, localTime);
|
const safeLocalTime = Math.max(0, localTime);
|
||||||
return {
|
return {
|
||||||
position: resolveAnimationPathValueAtTime({
|
position: {
|
||||||
animations,
|
x: resolveAnimationPathValueAtTime({
|
||||||
propertyPath: "transform.position",
|
animations,
|
||||||
localTime: safeLocalTime,
|
propertyPath: "transform.positionX",
|
||||||
fallbackValue: baseTransform.position,
|
localTime: safeLocalTime,
|
||||||
}),
|
fallbackValue: baseTransform.position.x,
|
||||||
|
}),
|
||||||
|
y: resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.positionY",
|
||||||
|
localTime: safeLocalTime,
|
||||||
|
fallbackValue: baseTransform.position.y,
|
||||||
|
}),
|
||||||
|
},
|
||||||
scaleX: resolveAnimationPathValueAtTime({
|
scaleX: resolveAnimationPathValueAtTime({
|
||||||
animations,
|
animations,
|
||||||
propertyPath: "transform.scaleX",
|
propertyPath: "transform.scaleX",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { ParamValues } from "@/lib/params";
|
||||||
|
|
||||||
export const ANIMATION_PROPERTY_PATHS = [
|
export const ANIMATION_PROPERTY_PATHS = [
|
||||||
"transform.position",
|
"transform.positionX",
|
||||||
|
"transform.positionY",
|
||||||
"transform.scaleX",
|
"transform.scaleX",
|
||||||
"transform.scaleY",
|
"transform.scaleY",
|
||||||
"transform.rotate",
|
"transform.rotate",
|
||||||
|
|
@ -34,7 +35,8 @@ export type VectorValue = { x: number; y: number };
|
||||||
export type DiscreteValue = boolean | string;
|
export type DiscreteValue = boolean | string;
|
||||||
export type AnimationValue = number | string | boolean | VectorValue;
|
export type AnimationValue = number | string | boolean | VectorValue;
|
||||||
export interface AnimationPropertyValueMap {
|
export interface AnimationPropertyValueMap {
|
||||||
"transform.position": VectorValue;
|
"transform.positionX": number;
|
||||||
|
"transform.positionY": number;
|
||||||
"transform.scaleX": number;
|
"transform.scaleX": number;
|
||||||
"transform.scaleY": number;
|
"transform.scaleY": number;
|
||||||
"transform.rotate": number;
|
"transform.rotate": number;
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,58 @@
|
||||||
export { StorageMigration } from "./base";
|
export { StorageMigration } from "./base";
|
||||||
import { V0toV1Migration } from "./v0-to-v1";
|
import { V0toV1Migration } from "./v0-to-v1";
|
||||||
import { V1toV2Migration } from "./v1-to-v2";
|
import { V1toV2Migration } from "./v1-to-v2";
|
||||||
import { V2toV3Migration } from "./v2-to-v3";
|
import { V2toV3Migration } from "./v2-to-v3";
|
||||||
import { V3toV4Migration } from "./v3-to-v4";
|
import { V3toV4Migration } from "./v3-to-v4";
|
||||||
import { V4toV5Migration } from "./v4-to-v5";
|
import { V4toV5Migration } from "./v4-to-v5";
|
||||||
import { V5toV6Migration } from "./v5-to-v6";
|
import { V5toV6Migration } from "./v5-to-v6";
|
||||||
import { V6toV7Migration } from "./v6-to-v7";
|
import { V6toV7Migration } from "./v6-to-v7";
|
||||||
import { V7toV8Migration } from "./v7-to-v8";
|
import { V7toV8Migration } from "./v7-to-v8";
|
||||||
import { V8toV9Migration } from "./v8-to-v9";
|
import { V8toV9Migration } from "./v8-to-v9";
|
||||||
import { V9toV10Migration } from "./v9-to-v10";
|
import { V9toV10Migration } from "./v9-to-v10";
|
||||||
import { V10toV11Migration } from "./v10-to-v11";
|
import { V10toV11Migration } from "./v10-to-v11";
|
||||||
import { V11toV12Migration } from "./v11-to-v12";
|
import { V11toV12Migration } from "./v11-to-v12";
|
||||||
import { V12toV13Migration } from "./v12-to-v13";
|
import { V12toV13Migration } from "./v12-to-v13";
|
||||||
import { V13toV14Migration } from "./v13-to-v14";
|
import { V13toV14Migration } from "./v13-to-v14";
|
||||||
import { V14toV15Migration } from "./v14-to-v15";
|
import { V14toV15Migration } from "./v14-to-v15";
|
||||||
import { V15toV16Migration } from "./v15-to-v16";
|
import { V15toV16Migration } from "./v15-to-v16";
|
||||||
import { V16toV17Migration } from "./v16-to-v17";
|
import { V16toV17Migration } from "./v16-to-v17";
|
||||||
import { V17toV18Migration } from "./v17-to-v18";
|
import { V17toV18Migration } from "./v17-to-v18";
|
||||||
import { V18toV19Migration } from "./v18-to-v19";
|
import { V18toV19Migration } from "./v18-to-v19";
|
||||||
import { V19toV20Migration } from "./v19-to-v20";
|
import { V19toV20Migration } from "./v19-to-v20";
|
||||||
import { V20toV21Migration } from "./v20-to-v21";
|
import { V20toV21Migration } from "./v20-to-v21";
|
||||||
import { V21toV22Migration } from "./v21-to-v22";
|
import { V21toV22Migration } from "./v21-to-v22";
|
||||||
import { V22toV23Migration } from "./v22-to-v23";
|
import { V22toV23Migration } from "./v22-to-v23";
|
||||||
import { V23toV24Migration } from "./v23-to-v24";
|
import { V23toV24Migration } from "./v23-to-v24";
|
||||||
export { runStorageMigrations } from "./runner";
|
import { V24toV25Migration } from "./v24-to-v25";
|
||||||
export type { MigrationProgress } from "./runner";
|
export { runStorageMigrations } from "./runner";
|
||||||
|
export type { MigrationProgress } from "./runner";
|
||||||
export const CURRENT_PROJECT_VERSION = 24;
|
|
||||||
|
export const CURRENT_PROJECT_VERSION = 25;
|
||||||
export const migrations = [
|
|
||||||
new V0toV1Migration(),
|
export const migrations = [
|
||||||
new V1toV2Migration(),
|
new V0toV1Migration(),
|
||||||
new V2toV3Migration(),
|
new V1toV2Migration(),
|
||||||
new V3toV4Migration(),
|
new V2toV3Migration(),
|
||||||
new V4toV5Migration(),
|
new V3toV4Migration(),
|
||||||
new V5toV6Migration(),
|
new V4toV5Migration(),
|
||||||
new V6toV7Migration(),
|
new V5toV6Migration(),
|
||||||
new V7toV8Migration(),
|
new V6toV7Migration(),
|
||||||
new V8toV9Migration(),
|
new V7toV8Migration(),
|
||||||
new V9toV10Migration(),
|
new V8toV9Migration(),
|
||||||
new V10toV11Migration(),
|
new V9toV10Migration(),
|
||||||
new V11toV12Migration(),
|
new V10toV11Migration(),
|
||||||
new V12toV13Migration(),
|
new V11toV12Migration(),
|
||||||
new V13toV14Migration(),
|
new V12toV13Migration(),
|
||||||
new V14toV15Migration(),
|
new V13toV14Migration(),
|
||||||
new V15toV16Migration(),
|
new V14toV15Migration(),
|
||||||
new V16toV17Migration(),
|
new V15toV16Migration(),
|
||||||
new V17toV18Migration(),
|
new V16toV17Migration(),
|
||||||
new V18toV19Migration(),
|
new V17toV18Migration(),
|
||||||
new V19toV20Migration(),
|
new V18toV19Migration(),
|
||||||
new V20toV21Migration(),
|
new V19toV20Migration(),
|
||||||
new V21toV22Migration(),
|
new V20toV21Migration(),
|
||||||
new V22toV23Migration(),
|
new V21toV22Migration(),
|
||||||
new V23toV24Migration(),
|
new V22toV23Migration(),
|
||||||
];
|
new V23toV24Migration(),
|
||||||
|
new V24toV25Migration(),
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import type { MigrationResult, ProjectRecord } from "./types";
|
||||||
|
import { getProjectId, isRecord } from "./utils";
|
||||||
|
|
||||||
|
export function transformProjectV24ToV25({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: ProjectRecord;
|
||||||
|
}): MigrationResult<ProjectRecord> {
|
||||||
|
if (!getProjectId({ project })) {
|
||||||
|
return { project, skipped: true, reason: "no project id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = project.version;
|
||||||
|
if (typeof version !== "number") {
|
||||||
|
return { project, skipped: true, reason: "invalid version" };
|
||||||
|
}
|
||||||
|
if (version >= 25) {
|
||||||
|
return { project, skipped: true, reason: "already v25" };
|
||||||
|
}
|
||||||
|
if (version !== 24) {
|
||||||
|
return { project, skipped: true, reason: "not v24" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
...migrateProject({ project }),
|
||||||
|
version: 25,
|
||||||
|
},
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateProject({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: ProjectRecord;
|
||||||
|
}): ProjectRecord {
|
||||||
|
if (!Array.isArray(project.scenes)) {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
scenes: project.scenes.map((scene) => migrateScene({ scene })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateScene({ scene }: { scene: unknown }): unknown {
|
||||||
|
if (!isRecord(scene) || !isRecord(scene.tracks)) {
|
||||||
|
return scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks = scene.tracks;
|
||||||
|
const nextTracks: ProjectRecord = { ...tracks };
|
||||||
|
|
||||||
|
if (isRecord(tracks.main)) {
|
||||||
|
nextTracks.main = migrateTrack({ track: tracks.main });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(tracks.overlay)) {
|
||||||
|
nextTracks.overlay = tracks.overlay.map((track) =>
|
||||||
|
migrateTrack({ track }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(tracks.audio)) {
|
||||||
|
nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...scene, tracks: nextTracks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateTrack({ track }: { track: unknown }): unknown {
|
||||||
|
if (!isRecord(track) || !Array.isArray(track.elements)) {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...track,
|
||||||
|
elements: track.elements.map((element) => migrateElement({ element })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateElement({ element }: { element: unknown }): unknown {
|
||||||
|
if (!isRecord(element) || !isRecord(element.animations)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAnimations = migrateAnimations({ animations: element.animations });
|
||||||
|
if (nextAnimations === element.animations) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...element, animations: nextAnimations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateAnimations({
|
||||||
|
animations,
|
||||||
|
}: {
|
||||||
|
animations: ProjectRecord;
|
||||||
|
}): ProjectRecord {
|
||||||
|
if (!isRecord(animations.bindings) || !isRecord(animations.channels)) {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionBinding = animations.bindings["transform.position"];
|
||||||
|
if (!isRecord(positionBinding) || positionBinding.kind !== "vector2") {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xChannel = animations.channels["transform.position:x"];
|
||||||
|
const yChannel = animations.channels["transform.position:y"];
|
||||||
|
|
||||||
|
const nextBindings: ProjectRecord = { ...animations.bindings };
|
||||||
|
const nextChannels: ProjectRecord = { ...animations.channels };
|
||||||
|
|
||||||
|
delete nextBindings["transform.position"];
|
||||||
|
delete nextChannels["transform.position:x"];
|
||||||
|
delete nextChannels["transform.position:y"];
|
||||||
|
|
||||||
|
nextBindings["transform.positionX"] = {
|
||||||
|
path: "transform.positionX",
|
||||||
|
kind: "number",
|
||||||
|
components: [{ key: "value", channelId: "transform.positionX:value" }],
|
||||||
|
};
|
||||||
|
nextBindings["transform.positionY"] = {
|
||||||
|
path: "transform.positionY",
|
||||||
|
kind: "number",
|
||||||
|
components: [{ key: "value", channelId: "transform.positionY:value" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isRecord(xChannel)) {
|
||||||
|
nextChannels["transform.positionX:value"] = xChannel;
|
||||||
|
}
|
||||||
|
if (isRecord(yChannel)) {
|
||||||
|
nextChannels["transform.positionY:value"] = yChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...animations, bindings: nextBindings, channels: nextChannels };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { StorageMigration } from "./base";
|
||||||
|
import type { ProjectRecord } from "./transformers/types";
|
||||||
|
import { transformProjectV24ToV25 } from "./transformers/v24-to-v25";
|
||||||
|
|
||||||
|
export class V24toV25Migration extends StorageMigration {
|
||||||
|
from = 24;
|
||||||
|
to = 25;
|
||||||
|
|
||||||
|
async transform(project: ProjectRecord): Promise<{
|
||||||
|
project: ProjectRecord;
|
||||||
|
skipped: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
return transformProjectV24ToV25({ project });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue