summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties/sections
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
commit49426f19cd8eaed7cf6c47999b66fee7ee637a56 (patch)
tree1b37451529cfcb36e6cae79494c61b9c616077df /apps/web/src/components/editor/panels/properties/sections
parent9b94f89def4f96836c4f42a23a73e97fe647a43c (diff)
downloadopencut-49426f19cd8eaed7cf6c47999b66fee7ee637a56.tar.gz
feat: implement keyframe animation system
Add keyframe support for transform, opacity, and volume properties. Includes animation engine (interpolation, mutations, resolvers), timeline markers with selection/snapping, properties panel toggles, keyframe-aware renderer, and full undo/redo command support. Also refactor element command constructors to object params, extract timeline pixel math to pixel-utils.ts, and update cursor rules.
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/sections')
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx457
1 files changed, 290 insertions, 167 deletions
diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
index 21da36e9..3fdb47dc 100644
--- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx
+++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
@@ -1,9 +1,15 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import { clamp } from "@/utils/math";
-import type { ElementType, Transform } from "@/types/timeline";
-import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "../section";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { AnimationPropertyPath } from "@/types/animation";
+import type { VisualElement } from "@/types/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+} from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -13,71 +19,125 @@ import {
} from "@hugeicons/core-free-icons";
import { useState } from "react";
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
+import { KeyframeToggle } from "../keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-type TransformElement = {
- id: string;
- transform: Transform;
- type: ElementType;
-};
-
-function parseFloat_({ input }: { input: string }): number | null {
+function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
+function isPropertyAtDefault({
+ hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ staticValue,
+ defaultValue,
+}: {
+ hasAnimatedKeyframes: boolean;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number;
+ staticValue: number;
+ defaultValue: number;
+}): boolean {
+ if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
+ return isNearlyEqual({
+ leftValue: resolvedValue,
+ rightValue: defaultValue,
+ });
+ }
+
+ return staticValue === defaultValue;
+}
+
export function TransformSection({
element,
trackId,
}: {
- element: TransformElement;
+ element: VisualElement;
trackId: string;
}) {
const editor = useEditor();
const [isScaleLocked, setIsScaleLocked] = useState(false);
+ const playheadTime = editor.playback.getCurrentTime();
+ const localTime = getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ const isPlayheadWithinElementRange =
+ playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
+ playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
- const previewTransform = (transform: Partial<Transform>) => {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { transform: { ...element.transform, ...transform } },
+ const positionX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position.x",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.position.x).toString(),
+ parse: (input) => parseNumericInput({ input }),
+ valueAtPlayhead: resolvedTransform.position.x,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ x: value,
},
- ],
- });
- };
-
- const commit = () => editor.timeline.commitPreview();
-
- const positionX = usePropertyDraft({
- displayValue: Math.round(element.transform.position.x).toString(),
- parse: (input) => parseFloat_({ input }),
- onPreview: (value) =>
- previewTransform({
- position: { ...element.transform.position, x: value },
- }),
- onCommit: commit,
+ },
+ }),
});
- const positionY = usePropertyDraft({
- displayValue: Math.round(element.transform.position.y).toString(),
- parse: (input) => parseFloat_({ input }),
- onPreview: (value) =>
- previewTransform({
- position: { ...element.transform.position, y: value },
- }),
- onCommit: commit,
+ const positionY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position.y",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.position.y).toString(),
+ parse: (input) => parseNumericInput({ input }),
+ valueAtPlayhead: resolvedTransform.position.y,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ y: value,
+ },
+ },
+ }),
});
- const scale = usePropertyDraft({
- displayValue: Math.round(element.transform.scale * 100).toString(),
+ const scale = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scale",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scale * 100).toString(),
parse: (input) => {
- const parsed = parseFloat_({ input });
+ const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return Math.max(parsed, 1) / 100;
},
- onPreview: (value) => previewTransform({ scale: value }),
- onCommit: commit,
+ valueAtPlayhead: resolvedTransform.scale,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scale: value,
+ },
+ }),
});
const scaleFieldProps = {
className: "flex-1",
@@ -88,66 +148,148 @@ export function TransformSection({
dragSensitivity: "slow" as const,
onScrub: scale.scrubTo,
onScrubEnd: scale.commitScrub,
- onReset: () =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- scale: DEFAULT_TRANSFORM.scale,
- },
- },
- },
- ],
- }),
- isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
+ onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scale,
+ staticValue: element.transform.scale,
+ defaultValue: DEFAULT_TRANSFORM.scale,
+ }),
};
- const rotation = usePropertyDraft({
- displayValue: Math.round(element.transform.rotate).toString(),
+ const rotation = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.rotate",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.rotate).toString(),
parse: (input) => {
- const parsed = parseFloat_({ input });
+ const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return clamp({ value: parsed, min: -360, max: 360 });
},
- onPreview: (value) => previewTransform({ rotate: value }),
- onCommit: commit,
+ valueAtPlayhead: resolvedTransform.rotate,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
});
+ const hasPositionKeyframe =
+ positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
+
+ const togglePositionKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
+ const keyframesToRemove: Array<{
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ }> = [];
+ if (positionX.keyframeIdAtTime) {
+ keyframesToRemove.push({
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.x" as const,
+ keyframeId: positionX.keyframeIdAtTime,
+ });
+ }
+ if (positionY.keyframeIdAtTime) {
+ keyframesToRemove.push({
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.y" as const,
+ keyframeId: positionY.keyframeIdAtTime,
+ });
+ }
+
+ editor.timeline.removeKeyframes({
+ keyframes: keyframesToRemove,
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.x",
+ time: localTime,
+ value: resolvedTransform.position.x,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.y",
+ time: localTime,
+ value: resolvedTransform.position.y,
+ },
+ ],
+ });
+ };
+
return (
<Section collapsible sectionKey={`${element.type}:transform`}>
<SectionHeader title="Transform" />
- <SectionContent>
- <SectionFields>
- <SectionField label="Scale">
- <div className="flex items-center gap-2">
- {isScaleLocked ? (
- <>
- <NumberField icon="W" {...scaleFieldProps} />
- <NumberField icon="H" {...scaleFieldProps} />
- </>
- ) : (
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldProps}
- className="flex-1"
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Scale"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scale.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle scale keyframe"
+ onToggle={scale.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ {isScaleLocked ? (
+ <>
+ <NumberField icon="W" {...scaleFieldProps} />
+ <NumberField icon="H" {...scaleFieldProps} />
+ </>
+ ) : (
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldProps}
+ className="flex-1"
+ />
+ )}
+ <Button
+ type="button"
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ </div>
+ </SectionField>
+ <SectionField
+ label="Position"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={hasPositionKeyframe}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle position keyframe"
+ onToggle={togglePositionKeyframe}
/>
- )}
- <Button
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- </div>
- </SectionField>
- <SectionField label="Position">
- <div className="flex items-center gap-2">
+ }
+ >
+ <div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
@@ -158,27 +300,15 @@ export function TransformSection({
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- x: DEFAULT_TRANSFORM.position.x,
- },
- },
- },
- },
- ],
- })
- }
- isDefault={
- element.transform.position.x === DEFAULT_TRANSFORM.position.x
+ positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULT_TRANSFORM.position.x,
+ })}
/>
<NumberField
icon="Y"
@@ -190,63 +320,56 @@ export function TransformSection({
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- y: DEFAULT_TRANSFORM.position.y,
- },
- },
- },
- },
- ],
- })
- }
- isDefault={
- element.transform.position.y === DEFAULT_TRANSFORM.position.y
+ positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULT_TRANSFORM.position.y,
+ })}
/>
- </div>
- </SectionField>
+ </div>
+ </SectionField>
- <SectionField label="Rotation">
- <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={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- rotate: DEFAULT_TRANSFORM.rotate,
- },
- },
- },
- ],
- })
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ onToggle={rotation.toggleKeyframe}
+ />
}
- isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
+ >
+ <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: DEFAULT_TRANSFORM.rotate })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.rotate,
+ staticValue: element.transform.rotate,
+ defaultValue: DEFAULT_TRANSFORM.rotate,
+ })}
+ />
+ </div>
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
</Section>
);
}