summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties
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
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')
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts151
-rw-r--r--apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx32
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx11
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx457
4 files changed, 482 insertions, 169 deletions
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
new file mode 100644
index 00000000..cd62ca75
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -0,0 +1,151 @@
+import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
+import type { ElementUpdatePatch } from "@/types/timeline";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedNumberProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue,
+ parse,
+ valueAtPlayhead,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ displayValue: string;
+ parse: (input: string) => number | null;
+ valueAtPlayhead: number;
+ buildBaseUpdates: ({ value }: { value: number }) => ElementUpdatePatch;
+}) {
+ const editor = useEditor();
+
+ 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 previewValue = ({ value }: { value: number }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ const propertyDraft = usePropertyDraft({
+ displayValue,
+ parse,
+ onPreview: (value) => previewValue({ value }),
+ 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: valueAtPlayhead,
+ },
+ ],
+ });
+ };
+
+ const commitValue = ({ value }: { value: number }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ return {
+ ...propertyDraft,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitValue,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx
new file mode 100644
index 00000000..b5b8685f
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx
@@ -0,0 +1,32 @@
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { KeyframeIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+export function KeyframeToggle({
+ isActive,
+ isDisabled = false,
+ title,
+ onToggle,
+}: {
+ isActive: boolean;
+ isDisabled?: boolean;
+ title: string;
+ onToggle: () => void;
+}) {
+ return (
+ <Button
+ variant="text"
+ aria-pressed={isActive}
+ disabled={isDisabled}
+ title={title}
+ onClick={onToggle}
+ className="[&>svg]:size-3.5 mb-0.5"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(isActive && "text-primary fill-primary")}
+ />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx
index 72d78d80..3edd17b5 100644
--- a/apps/web/src/components/editor/panels/properties/section.tsx
+++ b/apps/web/src/components/editor/panels/properties/section.tsx
@@ -139,21 +139,28 @@ export function SectionFields({
children: React.ReactNode;
className?: string;
}) {
- return <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>;
+ return (
+ <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>
+ );
}
export function SectionField({
label,
+ beforeLabel,
children,
className,
}: {
label: string;
+ beforeLabel?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-2", className)}>
- <Label>{label}</Label>
+ <div className="flex items-center gap-1.5">
+ {beforeLabel}
+ <Label>{label}</Label>
+ </div>
{children}
</div>
);
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>
);
}