summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/properties
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components/editor/panels/properties')
-rw-r--r--apps/web/src/components/editor/panels/properties/audio-properties.tsx3
-rw-r--r--apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx215
-rw-r--r--apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx (renamed from apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx)0
-rw-r--r--apps/web/src/components/editor/panels/properties/components/property-param-field.tsx194
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-param-field.tsx152
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-properties.tsx58
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts4
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts26
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts148
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts190
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx141
-rw-r--r--apps/web/src/components/editor/panels/properties/registry.tsx303
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx262
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx382
-rw-r--r--apps/web/src/components/editor/panels/properties/stores/properties-store.ts18
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx109
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx (renamed from apps/web/src/components/editor/panels/properties/sections/blending.tsx)196
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx309
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx238
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx660
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx137
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx (renamed from apps/web/src/components/editor/panels/properties/text-properties.tsx)136
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx424
-rw-r--r--apps/web/src/components/editor/panels/properties/video-properties.tsx25
25 files changed, 3010 insertions, 1322 deletions
diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
deleted file mode 100644
index 2ad46497..00000000
--- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export function AudioProperties() {
- return <div className="space-y-4 p-5">Audio properties</div>;
-}
diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
deleted file mode 100644
index a1324bdf..00000000
--- a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import type { Effect } from "@/types/effects";
-import type { VisualElement } from "@/types/timeline";
-import { getEffect } from "@/lib/effects/registry";
-import { useEditor } from "@/hooks/use-editor";
-import { usePropertiesStore } from "@/stores/properties-store";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
- SectionFields,
-} from "./section";
-import { EffectParamField } from "./effect-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowLeft01Icon,
- Delete02Icon,
- ViewIcon,
- ViewOffSlashIcon,
-} from "@hugeicons/core-free-icons";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { cn } from "@/utils/ui";
-export function ClipEffectsProperties({
- element,
- trackId,
-}: {
- element: VisualElement;
- trackId: string;
-}) {
- const closeClipEffects = usePropertiesStore(
- (state) => state.closeClipEffects,
- );
- const editor = useEditor();
- const effects = element.effects ?? [];
-
- useEffect(() => {
- if (effects.length === 0) closeClipEffects();
- }, [effects.length, closeClipEffects]);
-
- const [dragIndex, setDragIndex] = useState<number | null>(null);
- const [dropIndex, setDropIndex] = useState<number | null>(null);
-
- const handleDragStart = ({ index }: { index: number }) => {
- setDragIndex(index);
- };
-
- const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => {
- event.preventDefault();
- if (index !== dropIndex) setDropIndex(index);
- };
-
- const handleDrop = ({ toIndex }: { toIndex: number }) => {
- if (dragIndex !== null && dragIndex !== toIndex) {
- editor.timeline.reorderClipEffects({
- trackId,
- elementId: element.id,
- fromIndex: dragIndex,
- toIndex,
- });
- }
- setDragIndex(null);
- setDropIndex(null);
- };
-
- const handleDragEnd = () => {
- setDragIndex(null);
- setDropIndex(null);
- };
-
- return (
- <div className="flex h-full flex-col">
- <div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5">
- <Button
- variant="ghost"
- size="icon"
- onClick={closeClipEffects}
- aria-label="Back to properties"
- >
- <HugeiconsIcon icon={ArrowLeft01Icon} />
- </Button>
- <span className="text-sm font-medium">Effects</span>
- </div>
- <ScrollArea className="flex-1 scrollbar-hidden">
- {effects.map((effect, index) => (
- // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder
- <div
- key={effect.id}
- draggable
- onDragStart={() => handleDragStart({ index })}
- onDragOver={(event) => handleDragOver({ event, index })}
- onDrop={() => handleDrop({ toIndex: index })}
- onDragEnd={handleDragEnd}
- className={cn(
- "group",
- dragIndex === index && "opacity-40",
- dropIndex === index &&
- dragIndex !== null &&
- dragIndex !== index &&
- (index < dragIndex
- ? "border-t-2 border-primary"
- : "border-b-2 border-primary"),
- )}
- >
- <ClipEffectSection
- effect={effect}
- element={element}
- trackId={trackId}
- />
- </div>
- ))}
- </ScrollArea>
- </div>
- );
-}
-
-function ClipEffectSection({
- effect,
- element,
- trackId,
-}: {
- effect: Effect;
- element: VisualElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = getEffect({ effectType: effect.type });
-
- const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => {
- const updatedEffects = (element.effects ?? []).map((existing) =>
- existing.id !== effect.id
- ? existing
- : { ...existing, params: { ...existing.params, [key]: value } },
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { effects: updatedEffects },
- },
- ],
- });
- };
-
- const commitParam = () => editor.timeline.commitPreview();
-
- const toggleEffect = () =>
- editor.timeline.toggleClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- });
-
- const removeEffect = () =>
- editor.timeline.removeClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- });
-
- return (
- <Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}>
- <SectionHeader
- className="cursor-move"
- trailing={
- <div className="flex items-center gap-1">
- <Button
- variant={effect.enabled ? "secondary" : "ghost"}
- size="icon"
- aria-label={`Toggle ${definition.name}`}
- onClick={toggleEffect}
- >
- <HugeiconsIcon
- icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
- />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Remove ${definition.name}`}
- onClick={removeEffect}
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- </div>
- }
- >
- <SectionTitle
- className={cn(!effect.enabled && "text-muted-foreground")}
- >
- {definition.name}
- </SectionTitle>
- </SectionHeader>
- {effect.enabled && (
- <SectionContent>
- <SectionFields>
- {definition.params.map((param) => (
- <EffectParamField
- key={param.key}
- param={param}
- value={effect.params[param.key] ?? param.default}
- onPreview={previewParam({ key: param.key })}
- onCommit={commitParam}
- />
- ))}
- </SectionFields>
- </SectionContent>
- )}
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx
index b5b8685f..b5b8685f 100644
--- a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx
+++ b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx
diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
new file mode 100644
index 00000000..e69c6918
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
+import { formatNumberForDisplay, snapToStep } from "@/utils/math";
+import { SectionField } from "@/components/section";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { ColorPicker } from "@/components/ui/color-picker";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { KeyframeToggle } from "./keyframe-toggle";
+
+export function PropertyParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+ keyframe,
+}: {
+ param: ParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ keyframe?: {
+ isActive: boolean;
+ isDisabled: boolean;
+ onToggle: () => void;
+ };
+}) {
+ return (
+ <SectionField
+ label={param.label}
+ beforeLabel={
+ keyframe ? (
+ <KeyframeToggle
+ isActive={keyframe.isActive}
+ isDisabled={keyframe.isDisabled}
+ title={`Toggle ${param.label.toLowerCase()} keyframe`}
+ onToggle={keyframe.onToggle}
+ />
+ ) : undefined
+ }
+ >
+ <ParamInput
+ param={param}
+ value={value}
+ onPreview={onPreview}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ );
+}
+
+function ParamInput({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: ParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ if (param.type === "number") {
+ return (
+ <NumberParamField
+ param={param}
+ value={typeof value === "number" ? value : Number(value)}
+ onPreview={onPreview}
+ onCommit={onCommit}
+ />
+ );
+ }
+
+ if (param.type === "boolean") {
+ return (
+ <Switch
+ checked={Boolean(value)}
+ onCheckedChange={(checked) => {
+ onPreview(checked);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ if (param.type === "select") {
+ return (
+ <Select
+ value={String(value)}
+ onValueChange={(selected) => {
+ onPreview(selected);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {param.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+ }
+
+ if (param.type === "color") {
+ return (
+ <ColorPicker
+ value={String(value).replace(/^#/, "").toUpperCase()}
+ onChange={(color) => onPreview(`#${color}`)}
+ onChangeEnd={(color) => {
+ onPreview(`#${color}`);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ return null;
+}
+
+function NumberParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: NumberParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+}) {
+ const { min, max, step, displayMultiplier = 1 } = param;
+ const displayValue = value * displayMultiplier;
+ const clampDisplayValue = (nextDisplayValue: number) =>
+ Math.max(
+ min,
+ max !== undefined ? Math.min(max, nextDisplayValue) : nextDisplayValue,
+ );
+
+ const previewFromDisplay = (displayVal: number) => {
+ const clamped = clampDisplayValue(
+ snapToStep({ value: displayVal, step }),
+ );
+ onPreview(clamped / displayMultiplier);
+ };
+
+ const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
+
+ const draft = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: displayValue,
+ maxFractionDigits,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clampDisplayValue(snapToStep({ value: parsed, step }));
+ },
+ onPreview: previewFromDisplay,
+ onCommit,
+ });
+
+ const handleReset = () => {
+ onPreview(param.default);
+ onCommit();
+ };
+
+ return (
+ <NumberField
+ icon={param.shortLabel}
+ value={draft.displayValue}
+ dragSensitivity="slow"
+ isDefault={value === param.default}
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ onScrub={previewFromDisplay}
+ onScrubEnd={onCommit}
+ onReset={handleReset}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
deleted file mode 100644
index 408d430a..00000000
--- a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-"use client";
-
-import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects";
-import { clamp } from "@/utils/math";
-import { SectionField } from "./section";
-import { Slider } from "@/components/ui/slider";
-import { NumberField } from "@/components/ui/number-field";
-import { Switch } from "@/components/ui/switch";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { usePropertyDraft } from "./hooks/use-property-draft";
-
-export function EffectParamField({
- param,
- value,
- onPreview,
- onCommit,
-}: {
- param: EffectParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
- onCommit: () => void;
-}) {
- return (
- <SectionField label={param.label}>
- <EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} />
- </SectionField>
- );
-}
-
-function EffectParamInput({
- param,
- value,
- onPreview,
- onCommit,
-}: {
- param: EffectParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
- onCommit: () => void;
-}) {
- if (param.type === "number") {
- return (
- <NumberParamField
- param={param}
- value={typeof value === "number" ? value : Number(value)}
- onPreview={onPreview}
- onCommit={onCommit}
- />
- );
- }
-
- if (param.type === "boolean") {
- return (
- <Switch
- checked={Boolean(value)}
- onCheckedChange={(checked) => {
- onPreview(checked);
- onCommit();
- }}
- />
- );
- }
-
- if (param.type === "select") {
- return (
- <Select
- value={String(value)}
- onValueChange={(selected) => {
- onPreview(selected);
- onCommit();
- }}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {param.options.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- );
- }
-
- if (param.type === "color") {
- return (
- <input
- type="color"
- className="h-8 w-full cursor-pointer rounded border"
- value={String(value)}
- onChange={(event) => onPreview(event.target.value)}
- onBlur={onCommit}
- />
- );
- }
-
- return null;
-}
-
-function NumberParamField({
- param,
- value,
- onPreview,
- onCommit,
-}: {
- param: NumberEffectParamDefinition;
- value: number;
- onPreview: (value: number) => void;
- onCommit: () => void;
-}) {
- const { min, max, step } = param;
-
- const draft = usePropertyDraft({
- displayValue: String(value),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min, max });
- },
- onPreview,
- onCommit,
- });
-
- return (
- <div className="flex items-center gap-3">
- <Slider
- className="flex-1"
- min={min}
- max={max}
- step={step}
- value={[value]}
- onValueChange={([newValue]) => onPreview(newValue)}
- onValueCommit={onCommit}
- />
- <NumberField
- className="w-16 shrink-0"
- value={draft.displayValue}
- onFocus={draft.onFocus}
- onChange={draft.onChange}
- onBlur={draft.onBlur}
- />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
deleted file mode 100644
index 75fc30a1..00000000
--- a/apps/web/src/components/editor/panels/properties/effect-properties.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client";
-
-import type { EffectElement } from "@/types/timeline";
-import { getEffect } from "@/lib/effects/registry";
-import { useEditor } from "@/hooks/use-editor";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionFields,
- SectionTitle,
-} from "./section";
-import { EffectParamField } from "./effect-param-field";
-
-export function EffectProperties({
- element,
- trackId,
-}: {
- element: EffectElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = getEffect({ effectType: element.effectType });
-
- const previewParam =
- ({ key }: { key: string }) =>
- (value: number | string | boolean) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { params: { ...element.params, [key]: value } },
- },
- ],
- });
-
- return (
- <Section showTopBorder={false}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {definition.params.map((param) => (
- <EffectParamField
- key={param.key}
- param={param}
- value={element.params[param.key] ?? param.default}
- onPreview={previewParam({ key: param.key })}
- onCommit={() => editor.timeline.commitPreview()}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
index dbd19e4c..957ee1c9 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -4,8 +4,8 @@ import {
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
export function useKeyframedColorProperty({
trackId,
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
index 11aa049c..0f0168f8 100644
--- 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
@@ -4,8 +4,9 @@ import {
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
@@ -18,6 +19,7 @@ export function useKeyframedNumberProperty({
displayValue,
parse,
valueAtPlayhead,
+ step,
buildBaseUpdates,
}: {
trackId: string;
@@ -29,9 +31,12 @@ export function useKeyframedNumberProperty({
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
+ step?: number;
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
}) {
const editor = useEditor();
+ const snapValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
@@ -43,6 +48,7 @@ export function useKeyframedNumberProperty({
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
@@ -54,7 +60,7 @@ export function useKeyframedNumberProperty({
animations,
propertyPath,
time: localTime,
- value,
+ value: nextValue,
}),
},
},
@@ -68,7 +74,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
- updates: buildBaseUpdates({ value }),
+ updates: buildBaseUpdates({ value: nextValue }),
},
],
});
@@ -76,7 +82,10 @@ export function useKeyframedNumberProperty({
const propertyDraft = usePropertyDraft({
displayValue,
- parse,
+ parse: (input) => {
+ const parsedValue = parse(input);
+ return parsedValue === null ? null : snapValue(parsedValue);
+ },
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
@@ -107,13 +116,14 @@ export function useKeyframedNumberProperty({
elementId,
propertyPath,
time: localTime,
- value: valueAtPlayhead,
+ value: snapValue(valueAtPlayhead),
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
editor.timeline.upsertKeyframes({
keyframes: [
@@ -122,7 +132,7 @@ export function useKeyframedNumberProperty({
elementId,
propertyPath,
time: localTime,
- value,
+ value: nextValue,
},
],
});
@@ -134,7 +144,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
- updates: buildBaseUpdates({ value }),
+ updates: buildBaseUpdates({ value: nextValue }),
},
],
});
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
new file mode 100644
index 00000000..335e3fb6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -0,0 +1,148 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import {
+ buildGraphicParamPath,
+ getKeyframeAtTime,
+ getParamDefaultInterpolation,
+ getParamValueKind,
+ hasKeyframesForPath,
+ upsertPathKeyframe,
+} from "@/lib/animation";
+import type {
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { ParamDefinition } from "@/lib/params";
+import type { TimelineElement } from "@/lib/timeline";
+
+export interface KeyframedParamPropertyResult {
+ hasAnimatedKeyframes: boolean;
+ isKeyframedAtTime: boolean;
+ keyframeIdAtTime: string | null;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ toggleKeyframe: () => void;
+}
+
+export function useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId,
+ animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ buildBaseUpdates,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number | string | boolean;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: number | string | boolean;
+ }) => Partial<TimelineElement>;
+}): KeyframedParamPropertyResult {
+ const editor = useEditor();
+ const propertyPath = buildGraphicParamPath({ paramKey: param.key });
+ 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: KeyframedParamPropertyResult["onPreview"] = (value) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value,
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({
+ param,
+ }),
+ numericRange:
+ param.type === "number"
+ ? { min: param.min, max: param.max, step: param.step }
+ : undefined,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ 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,
+ },
+ ],
+ });
+ };
+
+ return {
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ onPreview: previewValue,
+ onCommit: () => editor.timeline.commitPreview(),
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
new file mode 100644
index 00000000..188f05cb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
@@ -0,0 +1,190 @@
+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, updates: 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, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ return {
+ x,
+ y,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitX,
+ commitY,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index cb45996d..3f3ff1f3 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -1,86 +1,95 @@
"use client";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { AudioProperties } from "./audio-properties";
-import { VideoProperties } from "./video-properties";
-import { TextProperties } from "./text-properties";
-import { EffectProperties } from "./effect-properties";
-import { ClipEffectsProperties } from "./clip-effects-properties";
-import { EmptyView } from "./empty-view";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { usePropertiesStore } from "@/stores/properties-store";
-import { isVisualElement } from "@/lib/timeline";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
-
-function ElementProperties({
- track,
- element,
-}: {
- track: TimelineTrack;
- element: TimelineElement;
-}) {
- if (element.type === "text") {
- return <TextProperties element={element} trackId={track.id} />;
- }
- if (element.type === "audio") {
- return <AudioProperties />;
- }
- if (
- element.type === "video" ||
- element.type === "image" ||
- element.type === "sticker"
- ) {
- return <VideoProperties element={element} trackId={track.id} />;
- }
- if (element.type === "effect") {
- return <EffectProperties element={element} trackId={track.id} />;
- }
- return null;
-}
+import { usePropertiesStore } from "./stores/properties-store";
+import { getPropertiesConfig } from "./registry";
+import { cn } from "@/utils/ui";
+import { EmptyView } from "./empty-view";
export function PropertiesPanel() {
const editor = useEditor();
+ useEditor((e) => e.timeline.getTracks());
+ useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
- const clipEffectsTarget = usePropertiesStore(
- (state) => state.clipEffectsTarget,
- );
+ const { activeTabPerType, setActiveTab } = usePropertiesStore();
- const clipEffectsTrack = clipEffectsTarget
- ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId })
- : null;
- const clipEffectsElement = clipEffectsTrack?.elements.find(
- (element) => element.id === clipEffectsTarget?.elementId,
- );
- const isShowingClipEffects =
- clipEffectsTrack &&
- clipEffectsElement &&
- isVisualElement(clipEffectsElement);
+ if (selectedElements.length === 0) {
+ return (
+ <div className="panel bg-background flex h-full flex-col items-center justify-center overflow-hidden rounded-sm border">
+ <EmptyView />
+ </div>
+ );
+ }
+
+ if (selectedElements.length > 1) {
+ return (
+ <div className="panel bg-background flex h-full flex-col items-center justify-center overflow-hidden rounded-sm border">
+ <p className="text-muted-foreground text-sm">
+ {selectedElements.length} elements selected.0
+ </p>
+ </div>
+ );
+ }
+
+ const mediaAssets = editor.media.getAssets();
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: selectedElements,
});
+ const elementWithTrack = elementsWithTracks[0];
+
+ if (!elementWithTrack) return null;
+
+ const { element, track } = elementWithTrack;
+ const config = getPropertiesConfig({ element, mediaAssets });
+ const visibleTabs = config.tabs;
+
+ const storedTabId = activeTabPerType[element.type];
+ const isStoredTabVisible = visibleTabs.some((t) => t.id === storedTabId);
+ const activeTabId = isStoredTabVisible ? storedTabId : config.defaultTab;
+ const activeTab =
+ visibleTabs.find((t) => t.id === activeTabId) ?? visibleTabs[0];
+
+ if (!activeTab) return null;
return (
- <div className="panel bg-background h-full rounded-sm border overflow-hidden">
- {isShowingClipEffects ? (
- <ClipEffectsProperties
- element={clipEffectsElement}
- trackId={clipEffectsTrack.id}
- />
- ) : selectedElements.length > 0 ? (
- <ScrollArea className="h-full scrollbar-hidden">
- {elementsWithTracks.map(({ track, element }) => (
- <ElementProperties
- key={element.id}
- track={track}
- element={element}
- />
+ <div className="panel bg-background flex h-full overflow-hidden rounded-sm border">
+ <TooltipProvider delayDuration={0}>
+ <div className="flex shrink-0 flex-col gap-0.5 border-r p-1 scrollbar-hidden overflow-y-auto">
+ {visibleTabs.map((tab) => (
+ <Tooltip key={tab.id}>
+ <TooltipTrigger asChild>
+ <Button
+ variant={tab.id === activeTab.id ? "secondary" : "ghost"}
+ size="icon"
+ onClick={() => setActiveTab(element.type, tab.id)}
+ aria-label={tab.label}
+ className={cn(
+ "shrink-0",
+ "h-8 w-8",
+ tab.id !== activeTab.id && "text-muted-foreground",
+ )}
+ >
+ {tab.icon}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right">{tab.label}</TooltipContent>
+ </Tooltip>
))}
- </ScrollArea>
- ) : (
- <EmptyView />
- )}
+ </div>
+ </TooltipProvider>
+ <ScrollArea className="flex-1 scrollbar-hidden">
+ {activeTab.content({ trackId: track.id })}
+ </ScrollArea>
</div>
);
}
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx
new file mode 100644
index 00000000..706faef4
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/registry.tsx
@@ -0,0 +1,303 @@
+import type { ReactNode } from "react";
+import type {
+ EffectElement,
+ GraphicElement,
+ ImageElement,
+ MaskableElement,
+ RetimableElement,
+ StickerElement,
+ TextElement,
+ VisualElement,
+ VideoElement,
+ AudioElement,
+ TimelineElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ TextFontIcon,
+ ArrowExpandIcon,
+ RainDropIcon,
+ MusicNote03Icon,
+ MagicWand05Icon,
+ DashboardSpeed02Icon,
+} from "@hugeicons/core-free-icons";
+import { TransformTab } from "./tabs/transform-tab";
+import { BlendingTab } from "./tabs/blending-tab";
+import { AudioTab } from "./tabs/audio-tab";
+import { TextTab } from "./tabs/text-tab";
+import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
+import { MasksTab } from "./tabs/masks-tab";
+import { SpeedTab } from "./tabs/speed-tab";
+import { GraphicTab } from "./tabs/graphic-tab";
+import { OcShapesIcon } from "@opencut/ui/icons";
+
+export type TabContentProps = {
+ trackId: string;
+};
+
+export type PropertiesTabDef = {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ content: (props: TabContentProps) => ReactNode;
+};
+
+export type ElementPropertiesConfig = {
+ defaultTab: string;
+ tabs: PropertiesTabDef[];
+};
+
+function buildTransformTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "transform",
+ label: "Transform",
+ icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
+ content: ({ trackId }) => (
+ <TransformTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildBlendingTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "blending",
+ label: "Blending",
+ icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
+ content: ({ trackId }) => (
+ <BlendingTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildAudioTab({
+ element,
+}: {
+ element: AudioElement | VideoElement;
+}): PropertiesTabDef {
+ return {
+ id: "audio",
+ label: "Audio",
+ icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
+ content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildSpeedTab({
+ element,
+}: {
+ element: RetimableElement;
+}): PropertiesTabDef {
+ return {
+ id: "speed",
+ label: "Speed",
+ icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
+ content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildMasksTab({
+ element,
+}: {
+ element: MaskableElement;
+}): PropertiesTabDef {
+ return {
+ id: "masks",
+ label: "Masks",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildClipEffectsTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <ClipEffectsTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
+ return {
+ id: "text",
+ label: "Text",
+ icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
+ content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildGraphicTab({
+ element,
+}: {
+ element: GraphicElement;
+}): PropertiesTabDef {
+ return {
+ id: "graphic",
+ label: "Graphic",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildStandaloneEffectTab({
+ element,
+}: {
+ element: EffectElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <StandaloneEffectTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function getTextConfig({
+ element,
+}: {
+ element: TextElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "text",
+ tabs: [
+ buildTextTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ ],
+ };
+}
+
+function getVideoConfig({
+ element,
+ mediaAsset,
+}: {
+ element: VideoElement;
+ mediaAsset: MediaAsset | undefined;
+}): ElementPropertiesConfig {
+ const showAudioTab = mediaAsset?.hasAudio !== false;
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ ...(showAudioTab ? [buildAudioTab({ element })] : []),
+ buildSpeedTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getImageConfig({
+ element,
+}: {
+ element: ImageElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getStickerConfig({
+ element,
+}: {
+ element: StickerElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getGraphicConfig({
+ element,
+}: {
+ element: GraphicElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "graphic",
+ tabs: [
+ buildGraphicTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getAudioConfig({
+ element,
+}: {
+ element: AudioElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "audio",
+ tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
+ };
+}
+
+function getEffectConfig({
+ element,
+}: {
+ element: EffectElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "effects",
+ tabs: [buildStandaloneEffectTab({ element })],
+ };
+}
+
+export function getPropertiesConfig({
+ element,
+ mediaAssets,
+}: {
+ element: TimelineElement;
+ mediaAssets: MediaAsset[];
+}): ElementPropertiesConfig {
+ switch (element.type) {
+ case "text":
+ return getTextConfig({ element });
+ case "video": {
+ const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
+ return getVideoConfig({ element, mediaAsset });
+ }
+ case "image":
+ return getImageConfig({ element });
+ case "sticker":
+ return getStickerConfig({ element });
+ case "graphic":
+ return getGraphicConfig({ element });
+ case "audio":
+ return getAudioConfig({ element });
+ case "effect":
+ return getEffectConfig({ element });
+ }
+}
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx
deleted file mode 100644
index 5989108b..00000000
--- a/apps/web/src/components/editor/panels/properties/section.tsx
+++ /dev/null
@@ -1,262 +0,0 @@
-import { createContext, useContext, useEffect, useState } from "react";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { ArrowDownIcon } from "@hugeicons/core-free-icons";
-import { Label } from "@/components/ui/label";
-import { Button } from "@/components/ui/button";
-
-const sectionExpandedCache = new Map<string, boolean>();
-const mountedSectionKeys = new Set<string>();
-
-interface SectionContext {
- isOpen: boolean;
- toggle: () => void;
- collapsible: boolean;
-}
-
-const SectionCtx = createContext<SectionContext | null>(null);
-
-function useSectionContext() {
- return useContext(SectionCtx);
-}
-
-interface SectionProps {
- children: React.ReactNode;
- collapsible?: boolean;
- defaultOpen?: boolean;
- sectionKey?: string;
- className?: string;
- showTopBorder?: boolean;
- showBottomBorder?: boolean;
-}
-
-export function Section({
- children,
- collapsible = false,
- defaultOpen = true,
- sectionKey,
- className,
- showTopBorder = true,
- showBottomBorder = true,
-}: SectionProps) {
- const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
- const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
-
- useEffect(() => {
- if (!sectionKey) return;
- if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) {
- console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`);
- }
- mountedSectionKeys.add(sectionKey);
- return () => { mountedSectionKeys.delete(sectionKey); };
- }, [sectionKey]);
-
- const toggle = () => {
- const next = !isOpen;
- setIsOpen(next);
- if (sectionKey) sectionExpandedCache.set(sectionKey, next);
- };
-
- return (
- <SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
- <div
- className={cn(
- "flex flex-col",
- showTopBorder && "border-t",
- showBottomBorder && "last:border-b",
- className,
- )}
- >
- {children}
- </div>
- </SectionCtx.Provider>
- );
-}
-
-interface SectionHeaderProps {
- children?: React.ReactNode;
- trailing?: React.ReactNode;
- leading?: React.ReactNode;
- actions?: React.ReactNode;
- onClick?: () => void;
- className?: string;
-}
-
-export function SectionHeader({
- children,
- trailing,
- leading,
- actions,
- onClick,
- className,
-}: SectionHeaderProps) {
- const ctx = useSectionContext();
- const isCollapsible = ctx?.collapsible ?? false;
- const isOpen = ctx?.isOpen ?? true;
- const isInteractive = isCollapsible || !!onClick;
- const handleClick = isCollapsible ? ctx?.toggle : onClick;
-
- const chevronIcon = isCollapsible ? (
- <HugeiconsIcon
- icon={ArrowDownIcon}
- className={cn(
- "size-4 shrink-0 transition-transform duration-200 ease-out",
- isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground",
- )}
- />
- ) : null;
-
- const headerContent = (
- <>
- {leading}
- <div className="min-w-0 flex-1">{children}</div>
- {(trailing || chevronIcon) && (
- <div className="flex items-center">
- {trailing}
- {chevronIcon && (
- <Button
- variant="ghost"
- size="icon"
- aria-label={isOpen ? "Collapse section" : "Expand section"}
- onClick={(event) => {
- event.stopPropagation();
- handleClick?.();
- }}
- >
- {chevronIcon}
- </Button>
- )}
- </div>
- )}
- {actions}
- </>
- );
-
- if (!isInteractive) {
- return (
- <div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}>
- {headerContent}
- </div>
- );
- }
-
- return (
- // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here
- <div
- role="button"
- tabIndex={0}
- className={cn(
- "flex h-11 w-full cursor-pointer items-center gap-2 px-3.5",
- className,
- )}
- onClick={handleClick}
- onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }}
- >
- {headerContent}
- </div>
- );
-}
-
-export function SectionTitle({
- children,
- className,
- onClick,
-}: {
- children: React.ReactNode;
- className?: string;
- onClick?: () => void;
-}) {
- const ctx = useSectionContext();
- const isOpen = ctx?.isOpen ?? true;
-
- if (onClick) {
- return (
- <button
- type="button"
- className={cn(
- "cursor-pointer text-sm font-medium",
- isOpen ? "text-foreground" : "text-muted-foreground",
- className,
- )}
- onClick={onClick}
- >
- {children}
- </button>
- );
- }
-
- return (
- <span
- className={cn(
- "text-sm font-medium",
- isOpen ? "text-foreground" : "text-muted-foreground",
- className,
- )}
- >
- {children}
- </span>
- );
-}
-
-export function SectionFields({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- 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)}>
- <div className="flex h-4 items-center gap-1.5">
- {beforeLabel}
- <Label>{label}</Label>
- </div>
- {children}
- </div>
- );
-}
-
-export function SectionContent({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- const ctx = useSectionContext();
- const isCollapsible = ctx?.collapsible ?? false;
- const isOpen = ctx?.isOpen ?? true;
-
- if (isCollapsible) {
- return (
- <div
- className={cn(
- "grid transition-[grid-template-rows] duration-100 ease-out",
- isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
- )}
- >
- <div className="overflow-hidden">
- <div className={cn("p-4 pt-0", className)}>{children}</div>
- </div>
- </div>
- );
- }
-
- return <div className={cn("p-4 pt-0", className)}>{children}</div>;
-}
diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx
deleted file mode 100644
index 8be58f39..00000000
--- a/apps/web/src/components/editor/panels/properties/sections/index.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./transform";
-export * from "./blending";
diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
deleted file mode 100644
index 6aaaa1fe..00000000
--- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx
+++ /dev/null
@@ -1,382 +0,0 @@
-import { NumberField } from "@/components/ui/number-field";
-import { useEditor } from "@/hooks/use-editor";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import type { AnimationPropertyPath } from "@/types/animation";
-import type { VisualElement } from "@/types/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "../section";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Link05Icon,
- RotateClockwiseIcon,
-} 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";
-
-export function parseNumericInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : parsed;
-}
-
-export 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,
- showTopBorder = true,
-}: {
- element: VisualElement;
- trackId: string;
- showTopBorder?: boolean;
-}) {
- 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 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 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 = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scale",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scale * 100).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return Math.max(parsed, 1) / 100;
- },
- valueAtPlayhead: resolvedTransform.scale,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scale: value,
- },
- }),
- });
- const scaleFieldProps = {
- className: "flex-1",
- value: scale.displayValue,
- onFocus: scale.onFocus,
- onChange: scale.onChange,
- onBlur: scale.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scale.scrubTo,
- onScrubEnd: scale.commitScrub,
- 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 = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.rotate",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.rotate).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return clamp({ value: parsed, min: -360, max: 360 });
- },
- 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`}
- showTopBorder={showTopBorder}
- >
- <SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
- <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}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon="X"
- className="flex-1"
- value={positionX.displayValue}
- onFocus={positionX.onFocus}
- onChange={positionX.onChange}
- onBlur={positionX.onBlur}
- onScrub={positionX.scrubTo}
- onScrubEnd={positionX.commitScrub}
- onReset={() =>
- 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"
- className="flex-1"
- value={positionY.displayValue}
- onFocus={positionY.onFocus}
- onChange={positionY.onChange}
- onBlur={positionY.onBlur}
- onScrub={positionY.scrubTo}
- onScrubEnd={positionY.commitScrub}
- onReset={() =>
- 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>
-
- <SectionField
- label="Rotation"
- beforeLabel={
- <KeyframeToggle
- isActive={rotation.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle rotation keyframe"
- 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: 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>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
new file mode 100644
index 00000000..1783e3fc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
@@ -0,0 +1,18 @@
+import { create } from "zustand";
+
+interface PropertiesState {
+ activeTabPerType: Record<string, string>;
+ setActiveTab: (elementType: string, tabId: string) => void;
+ isTransformScaleLocked: boolean;
+ setTransformScaleLocked: (locked: boolean) => void;
+}
+
+export const usePropertiesStore = create<PropertiesState>()((set) => ({
+ activeTabPerType: {},
+ setActiveTab: (elementType, tabId) =>
+ set((state) => ({
+ activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
+ })),
+ isTransformScaleLocked: false,
+ setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+}));
diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
new file mode 100644
index 00000000..41b6ec6d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
@@ -0,0 +1,109 @@
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import { resolveNumberAtTime } from "@/lib/animation";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: resolvedVolume.toFixed(1),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: 0.1,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
index a50e84e7..c360817c 100644
--- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
@@ -1,13 +1,16 @@
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
-import {
- DEFAULT_BLEND_MODE,
- DEFAULT_OPACITY,
-} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
-import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
import {
Select,
SelectContent,
@@ -16,16 +19,17 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import type { BlendMode } from "@/types/rendering";
-import type { ElementType } from "@/types/timeline";
-import type { ElementAnimations } from "@/types/animation";
+import type { BlendMode } from "@/lib/rendering";
+import type { ElementType } from "@/lib/timeline";
+import type { ElementAnimations } from "@/lib/animation/types";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
-import { KeyframeToggle } from "../keyframe-toggle";
+import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation";
-import { isPropertyAtDefault } from "./transform";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { isPropertyAtDefault } from "./transform-tab";
type BlendingElement = {
id: string;
@@ -67,7 +71,7 @@ const BLEND_MODE_GROUPS = [
],
];
-export function BlendingSection({
+export function BlendingTab({
element,
trackId,
}: {
@@ -75,13 +79,19 @@ export function BlendingSection({
trackId: string;
}) {
const editor = useEditor();
- const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
- const didSelectRef = useRef(false);
+ const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
+ const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
const committedBlendModeRef = useRef(blendMode);
- if (!editor.timeline.isPreviewActive()) {
+ if (!isPreviewActive) {
committedBlendModeRef.current = blendMode;
}
+ const {
+ onPointerLeave,
+ onOpenChange: handleBlendModeOpenChange,
+ markCommitted,
+ } = useMenuPreview();
+
const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
@@ -103,14 +113,7 @@ export function BlendingSection({
],
});
}
- didSelectRef.current = true;
- };
-
- const handleBlendModeOpenChange = (isOpen: boolean) => {
- if (!isOpen) {
- if (!didSelectRef.current) editor.timeline.discardPreview();
- didSelectRef.current = false;
- }
+ markCommitted();
};
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
@@ -137,86 +140,91 @@ export function BlendingSection({
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
valueAtPlayhead: resolvedOpacity,
+ step: 0.01,
buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
- <Section collapsible sectionKey={`${element.type}:blending`}>
- <SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField
- label="Opacity"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={opacity.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle opacity keyframe"
- onToggle={opacity.toggleKeyframe}
- />
- }
- >
- <NumberField
- className="w-full"
- icon={
- <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ <Section collapsible sectionKey={`${element.id}:blending`}>
+ <SectionHeader>
+ <SectionTitle>Blending</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="Opacity"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={opacity.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle opacity keyframe"
+ onToggle={opacity.toggleKeyframe}
+ />
}
- value={opacity.displayValue}
- min={0}
- max={100}
- onFocus={opacity.onFocus}
- onChange={opacity.onChange}
- onBlur={opacity.onBlur}
- onScrub={opacity.scrubTo}
- onScrubEnd={opacity.commitScrub}
- onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOpacity,
- staticValue: element.opacity,
- defaultValue: DEFAULT_OPACITY,
- })}
- dragSensitivity="slow"
- />
- </SectionField>
- <SectionField label="Blend mode" className="w-1/2">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
>
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
+ <NumberField
className="w-full"
+ icon={
+ <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ }
+ value={opacity.displayValue}
+ min={0}
+ max={100}
+ onFocus={opacity.onFocus}
+ onChange={opacity.onChange}
+ onBlur={opacity.onBlur}
+ onScrub={opacity.scrubTo}
+ onScrubEnd={opacity.commitScrub}
+ onReset={() =>
+ opacity.commitValue({ value: DEFAULTS.element.opacity })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOpacity,
+ staticValue: element.opacity,
+ defaultValue: DEFAULTS.element.opacity,
+ })}
+ dragSensitivity="slow"
+ />
+ </SectionField>
+ <SectionField label="Blend mode" className="w-1/2">
+ <Select
+ value={committedBlendModeRef.current}
+ onOpenChange={handleBlendModeOpenChange}
+ onValueChange={commitBlendMode}
>
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36">
- {BLEND_MODE_GROUPS.map((group, groupIndex) => (
- <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
- {group.map((option) => (
- <SelectItem
- key={option.value}
- value={option.value}
- onPointerEnter={() =>
- previewBlendMode({ value: option.value as BlendMode })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
+ <SelectTrigger
+ icon={<HugeiconsIcon icon={RainDropIcon} />}
+ className="w-full"
+ >
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
+ {BLEND_MODE_GROUPS.map((group, groupIndex) => (
+ <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
+ {group.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ onPointerEnter={() =>
+ previewBlendMode({ value: option.value as BlendMode })
+ }
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+ <SelectSeparator />
+ ) : null}
+ </Fragment>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </div>
+ </SectionContent>
</Section>
);
}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
new file mode 100644
index 00000000..1b94ee2d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import { useState } from "react";
+import type { ParamValues } from "@/lib/params";
+import type { Effect } from "@/lib/effects/types";
+import type { EffectElement, VisualElement } from "@/lib/timeline";
+import { effectsRegistry } from "@/lib/effects";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ MagicWand05Icon,
+} from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { Separator } from "@/components/ui/separator";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+
+export function StandaloneEffectTab({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effect: Effect = {
+ id: element.id,
+ type: element.effectType,
+ params: element.params,
+ enabled: true,
+ };
+
+ const previewParam = (key: string) => (value: number | string | boolean) => {
+ previewUpdates({
+ params: { ...(renderElement as EffectElement).params, [key]: value },
+ });
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effect</SectionTitle>
+ </div>
+ <EffectSection
+ effect={effect}
+ renderParams={(renderElement as EffectElement).params}
+ previewParam={previewParam}
+ onCommit={commit}
+ />
+ </div>
+ );
+}
+
+export function ClipEffectsTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effects: Effect[] = element.effects ?? [];
+
+ const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
+ return (
+ (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
+ ?.params ??
+ effects.find((ef) => ef.id === effectId)?.params ??
+ {}
+ );
+ };
+
+ const buildPreviewParam =
+ (effectId: string) =>
+ (key: string) =>
+ (value: number | string | boolean) => {
+ const updatedEffects = (
+ (renderElement as VisualElement).effects ?? []
+ ).map((existing) =>
+ existing.id !== effectId
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ previewUpdates({ effects: updatedEffects });
+ };
+
+ const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
+
+ const handleDragOver = ({
+ event,
+ index,
+ }: {
+ event: React.DragEvent;
+ index: number;
+ }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effects</SectionTitle>
+ </div>
+ {effects.length === 0 ? (
+ <EmptyView />
+ ) : (
+ <ul className="flex flex-col">
+ {effects.map((effect, index) => {
+ const resolvedDragIndex = dragIndex ?? -1;
+ const isDragging = dragIndex === index;
+ const isDropTarget =
+ dropIndex === index && dragIndex !== null && dragIndex !== index;
+ const showTopDropIndicator =
+ isDropTarget && index < resolvedDragIndex;
+ const showBottomDropIndicator =
+ isDropTarget && index > resolvedDragIndex;
+
+ return (
+ <li
+ key={effect.id}
+ draggable
+ onDragStart={() => handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group list-none",
+ isDragging && "opacity-40",
+ showTopDropIndicator && "border-t-2 border-primary",
+ showBottomDropIndicator && "border-b-2 border-primary",
+ )}
+ >
+ <EffectSection
+ effect={effect}
+ renderParams={getRenderParams({ effectId: effect.id })}
+ previewParam={buildPreviewParam(effect.id)}
+ onCommit={commit}
+ onToggle={() =>
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ onRemove={() =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ />
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+function EmptyView() {
+ const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
+
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-10 text-muted-foreground"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No effects</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-44">
+ Add effects to this layer from the Assets panel.
+ </p>
+ </div>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setActiveTab("effects")}
+ >
+ Open effects
+ </Button>
+ </div>
+ );
+}
+
+function EffectSection({
+ effect,
+ renderParams,
+ previewParam,
+ onCommit,
+ onToggle,
+ onRemove,
+}: {
+ effect: Effect;
+ renderParams: ParamValues;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+ onToggle?: () => void;
+ onRemove?: () => void;
+}) {
+ const definition = effectsRegistry.get(effect.type);
+
+ return (
+ <Section
+ sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
+ showTopBorder={false}
+ >
+ <SectionHeader
+ className={cn(onToggle && "cursor-move")}
+ trailing={
+ onToggle && (
+ <div className="flex items-center gap-1">
+ <Button
+ variant={effect.enabled ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={`Toggle ${definition.name}`}
+ onClick={onToggle}
+ >
+ <HugeiconsIcon
+ icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name}`}
+ onClick={onRemove}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ )
+ }
+ >
+ <SectionTitle
+ className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
+ >
+ {definition.name}
+ </SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
+ >
+ <SectionFields>
+ {definition.params.map((param) => (
+ <div key={param.key} className="flex flex-col gap-3.5">
+ <div className="px-4">
+ <PropertyParamField
+ param={param}
+ value={renderParams[param.key] ?? param.default}
+ onPreview={previewParam(param.key)}
+ onCommit={onCommit}
+ />
+ </div>
+ <Separator />
+ </div>
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
new file mode 100644
index 00000000..22dae8cc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "../hooks/use-keyframed-param-property";
+import { resolveGraphicParamsAtTime } from "@/lib/animation";
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+import type { GraphicElement } from "@/lib/timeline";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && (
+ <StrokeSection element={element} trackId={trackId} />
+ )}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ });
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
new file mode 100644
index 00000000..6a5d0d0b
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
@@ -0,0 +1,660 @@
+"use client";
+
+import type { MaskableElement } from "@/lib/timeline";
+import type { Mask, MaskType } from "@/lib/masks/types";
+import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
+import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ FeatherIcon,
+ PlusSignIcon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { ColorPicker } from "@/components/ui/color-picker";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { NumberField } from "@/components/ui/number-field";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
+import { cn } from "@/utils/ui";
+
+type MasksTabProps = {
+ element: MaskableElement;
+ trackId: string;
+};
+
+type MaskItemProps = {
+ trackId: string;
+ elementId: string;
+ mask: Mask;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+};
+
+type EmptyViewProps = {
+ onAddMask: () => void;
+};
+
+type PreviewParamHandler = (
+ key: string,
+) => (value: number | string | boolean) => void;
+
+type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
+
+export function MasksTab({ element, trackId }: MasksTabProps) {
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } =
+ useElementPreview<MaskableElement>({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+ const maskDefs = masksRegistry.getAll();
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const masks = element.masks ?? [];
+ const renderMasks = renderElement.masks ?? masks;
+ const hasMask = masks.length > 0;
+ const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const elementBounds = useMemo(() => {
+ const clampedTime = Math.min(
+ Math.max(currentTime, element.startTime),
+ element.startTime + element.duration - TIME_EPSILON_SECONDS,
+ );
+
+ return (
+ getVisibleElementsWithBounds({
+ tracks,
+ currentTime: clampedTime,
+ canvasSize,
+ mediaAssets,
+ }).find(
+ (item) => item.trackId === trackId && item.elementId === element.id,
+ )?.bounds ?? null
+ );
+ }, [
+ canvasSize,
+ currentTime,
+ element.duration,
+ element.id,
+ element.startTime,
+ mediaAssets,
+ trackId,
+ tracks,
+ ]);
+
+ const handleDropdownOpenChange = (open: boolean) => {
+ if (hasMask && open) return;
+ setIsDropdownOpen(open);
+ onOpenChange(open);
+ };
+
+ const previewMask = ({ maskType }: { maskType: MaskType }) => {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ };
+
+ const commitMask = ({ maskType }: { maskType: MaskType }) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ }
+ markCommitted();
+ setIsDropdownOpen(false);
+ };
+
+ const previewMaskParam =
+ ({ index, key }: { index: number; key: string }) =>
+ (value: number | string | boolean) => {
+ if (!renderMasks[index]) {
+ return;
+ }
+
+ const updatedMasks = renderMasks.map((existingMask, maskIndex) =>
+ maskIndex !== index
+ ? existingMask
+ : {
+ ...existingMask,
+ params: {
+ ...existingMask.params,
+ [key]: value,
+ },
+ },
+ );
+
+ previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2">
+ <SectionTitle>Masks</SectionTitle>
+ <DropdownMenu
+ open={hasMask ? false : isDropdownOpen}
+ onOpenChange={handleDropdownOpenChange}
+ >
+ {hasMask ? (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="inline-flex">
+ <Button
+ variant="ghost"
+ size="icon"
+ disabled
+ aria-label="Add mask"
+ >
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
+ </Button>
+ </span>
+ </TooltipTrigger>
+ <TooltipContent className="max-w-56 text-balance">
+ Only one mask is supported right now. If you need more,
+ duplicate the clip and apply a different mask to each copy.
+ </TooltipContent>
+ </Tooltip>
+ ) : (
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon" aria-label="Add mask">
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
+ </Button>
+ </DropdownMenuTrigger>
+ )}
+ <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}>
+ {maskDefs.map((definition) => (
+ <DropdownMenuItem
+ key={definition.type}
+ onPointerEnter={() =>
+ previewMask({ maskType: definition.type })
+ }
+ onClick={() => commitMask({ maskType: definition.type })}
+ >
+ <HugeiconsIcon {...definition.icon} />
+ {definition.name}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {masks.length === 0 ? (
+ <EmptyView onAddMask={() => setIsDropdownOpen(true)} />
+ ) : (
+ masks.map((mask, index) => (
+ <MaskItem
+ key={mask.id}
+ trackId={trackId}
+ elementId={element.id}
+ mask={renderMasks[index] ?? mask}
+ previewParam={(paramKey) =>
+ previewMaskParam({ index, key: paramKey })
+ }
+ onCommit={commit}
+ />
+ ))
+ )}
+ </div>
+ );
+}
+
+function MaskItem({
+ trackId,
+ elementId,
+ mask,
+ previewParam,
+ onCommit,
+}: MaskItemProps) {
+ const editor = useEditor();
+ const definition = masksRegistry.get(mask.type);
+
+ return (
+ <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}>
+ <SectionHeader
+ trailing={
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Toggle ${definition.name} mask inversion`}
+ onClick={() =>
+ editor.timeline.toggleMaskInverted({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+ <OcMirrorIcon
+ className={cn(mask.params.inverted && "-scale-x-100")}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name} mask`}
+ onClick={() =>
+ editor.timeline.removeMask({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ }
+ >
+ <div className="flex items-center gap-2">
+ <HugeiconsIcon {...definition.icon} size={14} />
+ <SectionTitle className="capitalize font-normal">
+ {definition.name}
+ </SectionTitle>
+ </div>
+ </SectionHeader>
+ <SectionContent>
+ <MaskParamsFields
+ mask={mask}
+ definition={definition}
+ previewParam={previewParam}
+ onCommit={onCommit}
+ />
+ </SectionContent>
+ </Section>
+ );
+}
+
+function MaskParamsFields({
+ mask,
+ definition,
+ previewParam,
+ onCommit,
+}: {
+ mask: Mask;
+ definition: RegisteredMaskDefinition;
+ previewParam: PreviewParamHandler;
+ onCommit: () => void;
+}) {
+ const featherParam = getNumberParamDefinition({
+ definition,
+ key: "feather",
+ });
+ const strokeWidthParam = getNumberParamDefinition({
+ definition,
+ key: "strokeWidth",
+ });
+ const previewNumberParam = (key: string) => (value: number) =>
+ previewParam(key)(value);
+ const previewStrokeColor = previewParam("strokeColor");
+ const strokeAlignParam = definition.params.find(
+ (param): param is SelectParamDefinition =>
+ param.key === "strokeAlign" && param.type === "select",
+ );
+
+ return (
+ <SectionFields>
+ {definition.features.hasPosition &&
+ "centerX" in mask.params &&
+ "centerY" in mask.params && (
+ <SectionField label="Position">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="X"
+ param={getNumberParamDefinition({
+ definition,
+ key: "centerX",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "centerX",
+ })}
+ onPreview={previewNumberParam("centerX")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="flex-1"
+ icon="Y"
+ param={getNumberParamDefinition({
+ definition,
+ key: "centerY",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "centerY",
+ })}
+ onPreview={previewNumberParam("centerY")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "width-height" &&
+ "width" in mask.params &&
+ "height" in mask.params && (
+ <SectionField label="Size">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="W"
+ param={getNumberParamDefinition({
+ definition,
+ key: "width",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "width",
+ })}
+ onPreview={previewNumberParam("width")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="flex-1"
+ icon="H"
+ param={getNumberParamDefinition({
+ definition,
+ key: "height",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "height",
+ })}
+ onPreview={previewNumberParam("height")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "uniform" && "scale" in mask.params && (
+ <SectionField label="Scale">
+ <MaskNumberField
+ icon="S"
+ param={getNumberParamDefinition({
+ definition,
+ key: "scale",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "scale",
+ })}
+ onPreview={previewNumberParam("scale")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ {definition.features.hasRotation && "rotation" in mask.params && (
+ <SectionField label="Rotation">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ param={getNumberParamDefinition({
+ definition,
+ key: "rotation",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "rotation",
+ })}
+ onPreview={previewNumberParam("rotation")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ <SectionField label="Feather">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={FeatherIcon} />}
+ param={featherParam}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "feather",
+ })}
+ onPreview={previewNumberParam("feather")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+
+ <SectionField label="Stroke">
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="W"
+ param={strokeWidthParam}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "strokeWidth",
+ })}
+ onPreview={previewNumberParam("strokeWidth")}
+ onCommit={onCommit}
+ />
+ <ColorPicker
+ className=""
+ value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()}
+ onChange={(color) => previewStrokeColor(`#${color}`)}
+ onChangeEnd={(color) => {
+ previewStrokeColor(`#${color}`);
+ onCommit();
+ }}
+ />
+ </div>
+ {strokeAlignParam ? (
+ <Select
+ value={mask.params.strokeAlign}
+ onValueChange={(value) => {
+ previewParam("strokeAlign")(value);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {strokeAlignParam.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : null}
+ </div>
+ </SectionField>
+ </SectionFields>
+ );
+}
+
+function getNumberParamDefinition({
+ definition,
+ key,
+}: {
+ definition: RegisteredMaskDefinition;
+ key: string;
+}): NumberParamDefinition {
+ const param = definition.params.find((candidate) => candidate.key === key);
+
+ if (!param || param.type !== "number") {
+ throw new Error(`Missing number param definition for mask key "${key}"`);
+ }
+
+ return param;
+}
+
+function getMaskNumber({
+ params,
+ key,
+}: {
+ params: Mask["params"];
+ key: string;
+}): number {
+ const value = params[key];
+
+ if (typeof value !== "number") {
+ throw new Error(`Expected numeric mask param for "${key}"`);
+ }
+
+ return value;
+}
+
+function MaskNumberField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+ icon,
+ className,
+}: {
+ param: NumberParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+ icon?: React.ReactNode;
+ className?: string;
+}) {
+ const isPercent = param.unit === "percent";
+ const percentMax = param.max ?? 100;
+ const displayMultiplier = isPercent
+ ? 100 / percentMax
+ : (param.displayMultiplier ?? 1);
+ const min = isPercent ? 0 : param.min;
+ const max = isPercent ? 100 : param.max;
+ const step = isPercent ? 1 : param.step;
+ const displayValue = value * displayMultiplier;
+ const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
+
+ const clampDisplay = (nextDisplayValue: number) =>
+ max !== undefined
+ ? clamp({ value: nextDisplayValue, min, max })
+ : Math.max(min, nextDisplayValue);
+
+ const previewFromDisplay = (nextDisplayValue: number) => {
+ onPreview(
+ clampDisplay(snapToStep({ value: nextDisplayValue, step })) /
+ displayMultiplier,
+ );
+ };
+
+ const draft = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: displayValue,
+ maxFractionDigits,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
+ },
+ onPreview,
+ onCommit,
+ });
+
+ return (
+ <NumberField
+ className={className}
+ icon={icon}
+ value={draft.displayValue}
+ dragSensitivity="slow"
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ onScrub={previewFromDisplay}
+ onScrubEnd={onCommit}
+ />
+ );
+}
+
+function EmptyView({ onAddMask }: EmptyViewProps) {
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No masks</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-40">
+ Add a mask to hide or reveal parts of this layer.
+ </p>
+ </div>
+ <Button variant="default" size="sm" onClick={onAddMask}>
+ Add mask
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
new file mode 100644
index 00000000..44f5dd74
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
@@ -0,0 +1,137 @@
+import { useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
+import { buildConstantRetime } from "@/lib/retime";
+import {
+ DEFAULT_RETIME_RATE,
+ MIN_RETIME_RATE,
+ MAX_RETIME_RATE,
+ clampRetimeRate,
+ canMaintainPitch,
+} from "@/constants/retime-constants";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+
+function rateToDisplay({ rate }: { rate: number }): string {
+ return rate.toFixed(2);
+}
+
+function parseSpeedInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ const rounded = Math.round(parsed * 100) / 100;
+ return clampRetimeRate({ rate: rounded });
+}
+
+function buildRetime({
+ rate,
+ maintainPitch,
+}: {
+ rate: number;
+ maintainPitch: boolean;
+}) {
+ if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined;
+ return buildConstantRetime({ rate, maintainPitch });
+}
+
+export function SpeedTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const rate = clampRetimeRate({
+ rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
+ });
+ const maintainPitchAvailable = canMaintainPitch({ rate });
+ const maintainPitch = element.retime?.maintainPitch ?? false;
+ const pendingRateRef = useRef(rate);
+
+ const applyRetime = ({
+ rate: nextRate,
+ maintainPitch: nextMaintainPitch,
+ pushHistory = true,
+ }: {
+ rate: number;
+ maintainPitch: boolean;
+ pushHistory?: boolean;
+ }) => {
+ editor.timeline.updateElementRetime({
+ trackId,
+ elementId: element.id,
+ retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
+ pushHistory,
+ });
+ };
+
+ const speedDraft = usePropertyDraft({
+ displayValue: rateToDisplay({ rate }),
+ parse: (input) => parseSpeedInput({ input }),
+ onPreview: (nextRate) => {
+ pendingRateRef.current = nextRate;
+ applyRetime({ rate: nextRate, maintainPitch, pushHistory: false });
+ },
+ onCommit: () => {
+ applyRetime({ rate: pendingRateRef.current, maintainPitch });
+ },
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:speed`}>
+ <SectionHeader>
+ <SectionTitle>Speed</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Speed">
+ <NumberField
+ icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />}
+ value={speedDraft.displayValue}
+ suffix="x"
+ scrubRanges={[
+ { from: 0.01, to: 1, pixelsPerUnit: 160 },
+ { from: 1, to: 5, pixelsPerUnit: 48 },
+ ]}
+ scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }}
+ onFocus={() => {
+ pendingRateRef.current = rate;
+ speedDraft.onFocus();
+ }}
+ onChange={speedDraft.onChange}
+ onBlur={speedDraft.onBlur}
+ onScrub={speedDraft.scrubTo}
+ onScrubEnd={speedDraft.commitScrub}
+ onReset={() =>
+ applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
+ }
+ isDefault={rate === DEFAULT_RETIME_RATE}
+ />
+ </SectionField>
+ <div className="flex items-center justify-between">
+ <span className="text-sm">Change pitch</span>
+ <Switch
+ checked={!maintainPitch}
+ disabled={!maintainPitchAvailable}
+ onCheckedChange={(checked) =>
+ applyRetime({ rate, maintainPitch: !checked })
+ }
+ />
+ </div>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
index ae6de420..f8e2527a 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
@@ -1,6 +1,8 @@
+"use client";
+
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
-import type { TextElement } from "@/types/timeline";
+import type { TextElement } from "@/lib/timeline";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
import {
@@ -10,7 +12,7 @@ import {
SectionFields,
SectionHeader,
SectionTitle,
-} from "./section";
+} from "@/components/section";
import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
@@ -20,31 +22,27 @@ import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
- DEFAULT_LETTER_SPACING,
- DEFAULT_LINE_HEIGHT,
- DEFAULT_TEXT_BACKGROUND,
- DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
-import { usePropertyDraft } from "./hooks/use-property-draft";
-import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property";
-import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "./hooks/use-element-playhead";
-import { TransformSection, BlendingSection } from "./sections";
-import { KeyframeToggle } from "./keyframe-toggle";
-import { isPropertyAtDefault } from "./sections/transform";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { isPropertyAtDefault } from "./transform-tab";
import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import {
+ MinusSignIcon,
+ PlusSignIcon,
TextFontIcon,
- ViewIcon,
- ViewOffSlashIcon,
} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
+import { DEFAULTS } from "@/lib/timeline/defaults";
import { cn } from "@/utils/ui";
-export function TextProperties({
+export function TextTab({
element,
trackId,
}: {
@@ -52,10 +50,8 @@ export function TextProperties({
trackId: string;
}) {
return (
- <div className="flex h-full flex-col">
+ <div className="flex flex-col">
<ContentSection element={element} trackId={trackId} />
- <TransformSection element={element} trackId={trackId} />
- <BlendingSection element={element} trackId={trackId} />
<TypographySection element={element} trackId={trackId} />
<SpacingSection element={element} trackId={trackId} />
<BackgroundSection element={element} trackId={trackId} />
@@ -85,7 +81,7 @@ function ContentSection({
});
return (
- <Section collapsible sectionKey="text:content" showTopBorder={false}>
+ <Section collapsible sectionKey={`${element.id}:content`}>
<SectionHeader>
<SectionTitle>Content</SectionTitle>
</SectionHeader>
@@ -138,7 +134,11 @@ function TypographySection({
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
+ return clamp({
+ value: Math.round(parsed),
+ min: MIN_FONT_SIZE,
+ max: MAX_FONT_SIZE,
+ });
},
onPreview: (value) =>
editor.timeline.previewElements({
@@ -150,7 +150,7 @@ function TypographySection({
});
return (
- <Section collapsible sectionKey="text:typography">
+ <Section collapsible sectionKey={`${element.id}:typography`}>
<SectionHeader>
<SectionTitle>Typography</SectionTitle>
</SectionHeader>
@@ -188,12 +188,14 @@ function TypographySection({
{
trackId,
elementId: element.id,
- updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
+ updates: {
+ fontSize: DEFAULTS.text.element.fontSize,
+ },
},
],
})
}
- isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
icon={<HugeiconsIcon icon={TextFontIcon} />}
/>
</SectionField>
@@ -233,7 +235,7 @@ function SpacingSection({
const letterSpacing = usePropertyDraft({
displayValue: Math.round(
- element.letterSpacing ?? DEFAULT_LETTER_SPACING,
+ element.letterSpacing ?? DEFAULTS.text.letterSpacing,
).toString(),
parse: (input) => {
const parsed = parseFloat(input);
@@ -249,7 +251,7 @@ function SpacingSection({
});
const lineHeight = usePropertyDraft({
- displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
+ displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed)
@@ -266,7 +268,7 @@ function SpacingSection({
});
return (
- <Section collapsible sectionKey="text:spacing" showBottomBorder={false}>
+ <Section collapsible sectionKey={`${element.id}:spacing`}>
<SectionHeader>
<SectionTitle>Spacing</SectionTitle>
</SectionHeader>
@@ -286,14 +288,14 @@ function SpacingSection({
{
trackId,
elementId: element.id,
- updates: { letterSpacing: DEFAULT_LETTER_SPACING },
+ updates: { letterSpacing: DEFAULTS.text.letterSpacing },
},
],
})
}
isDefault={
- (element.letterSpacing ?? DEFAULT_LETTER_SPACING) ===
- DEFAULT_LETTER_SPACING
+ (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
+ DEFAULTS.text.letterSpacing
}
icon={<OcTextWidthIcon size={14} />}
/>
@@ -312,14 +314,14 @@ function SpacingSection({
{
trackId,
elementId: element.id,
- updates: { lineHeight: DEFAULT_LINE_HEIGHT },
+ updates: { lineHeight: DEFAULTS.text.lineHeight },
},
],
})
}
isDefault={
- (element.lineHeight ?? DEFAULT_LINE_HEIGHT) ===
- DEFAULT_LINE_HEIGHT
+ (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
+ DEFAULTS.text.lineHeight
}
icon={<OcTextHeightIcon size={14} />}
/>
@@ -366,25 +368,25 @@ function BackgroundSection({
const bg = element.background;
const resolvedPaddingX = resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
animations: element.animations,
propertyPath: "background.paddingX",
localTime,
});
const resolvedPaddingY = resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
animations: element.animations,
propertyPath: "background.paddingY",
localTime,
});
const resolvedOffsetX = resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
animations: element.animations,
propertyPath: "background.offsetX",
localTime,
});
const resolvedOffsetY = resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
animations: element.animations,
propertyPath: "background.offsetY",
localTime,
@@ -409,6 +411,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingX,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingX: value },
}),
@@ -427,6 +430,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingY,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingY: value },
}),
@@ -445,6 +449,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
valueAtPlayhead: resolvedOffsetX,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetX: value },
}),
@@ -463,6 +468,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
valueAtPlayhead: resolvedOffsetY,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetY: value },
}),
@@ -479,9 +485,14 @@ function BackgroundSection({
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
- return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX });
+ return clamp({
+ value: Math.round(parsed),
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ });
},
valueAtPlayhead: resolvedCornerRadius,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, cornerRadius: value },
}),
@@ -514,7 +525,7 @@ function BackgroundSection({
<Section
collapsible
defaultOpen={element.background.enabled}
- sectionKey="text:background"
+ sectionKey={`${element.id}:background`}
>
<SectionHeader
trailing={
@@ -527,7 +538,8 @@ function BackgroundSection({
}}
>
<HugeiconsIcon
- icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon}
+ icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
/>
</Button>
}
@@ -590,13 +602,17 @@ function BackgroundSection({
onBlur={paddingX.onBlur}
onScrub={paddingX.scrubTo}
onScrubEnd={paddingX.commitScrub}
- onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
+ onReset={() =>
+ paddingX.commitValue({
+ value: DEFAULTS.text.background.paddingX,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedPaddingX,
- staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
- defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
+ staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ defaultValue: DEFAULTS.text.background.paddingX,
})}
/>
</SectionField>
@@ -621,13 +637,17 @@ function BackgroundSection({
onBlur={paddingY.onBlur}
onScrub={paddingY.scrubTo}
onScrubEnd={paddingY.commitScrub}
- onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })}
+ onReset={() =>
+ paddingY.commitValue({
+ value: DEFAULTS.text.background.paddingY,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedPaddingY,
- staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
- defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY,
+ staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ defaultValue: DEFAULTS.text.background.paddingY,
})}
/>
</SectionField>
@@ -653,13 +673,17 @@ function BackgroundSection({
onBlur={offsetX.onBlur}
onScrub={offsetX.scrubTo}
onScrubEnd={offsetX.commitScrub}
- onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })}
+ onReset={() =>
+ offsetX.commitValue({
+ value: DEFAULTS.text.background.offsetX,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOffsetX,
- staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
- defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX,
+ staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ defaultValue: DEFAULTS.text.background.offsetX,
})}
/>
</SectionField>
@@ -683,13 +707,17 @@ function BackgroundSection({
onBlur={offsetY.onBlur}
onScrub={offsetY.scrubTo}
onScrubEnd={offsetY.commitScrub}
- onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })}
+ onReset={() =>
+ offsetY.commitValue({
+ value: DEFAULTS.text.background.offsetY,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOffsetY,
- staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
- defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY,
+ staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ defaultValue: DEFAULTS.text.background.offsetY,
})}
/>
</SectionField>
@@ -715,7 +743,9 @@ function BackgroundSection({
onBlur={cornerRadius.onBlur}
onScrub={cornerRadius.scrubTo}
onScrubEnd={cornerRadius.commitScrub}
- onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })}
+ onReset={() =>
+ cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
new file mode 100644
index 00000000..a015a9bb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
@@ -0,0 +1,424 @@
+import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { VisualElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Link05Icon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
+import { usePropertiesStore } from "../stores/properties-store";
+
+export function parseNumericInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export 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 TransformTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
+ const setTransformScaleLocked = usePropertiesStore(
+ (s) => s.setTransformScaleLocked,
+ );
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const position = useKeyframedVectorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position,
+ displayX: Math.round(resolvedTransform.position.x).toString(),
+ displayY: Math.round(resolvedTransform.position.y).toString(),
+ parseComponent: (input) => parseNumericInput({ input }),
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: { ...element.transform, position: value },
+ }),
+ });
+
+ const parseScale = (input: string) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return parsed / 100;
+ };
+
+ const scaleX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleX,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleX: value,
+ ...(isScaleLocked ? { scaleY: value } : {}),
+ },
+ }),
+ });
+
+ const scaleY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleY,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleY: value,
+ ...(isScaleLocked ? { scaleX: value } : {}),
+ },
+ }),
+ });
+
+ const scaleFieldPropsX = {
+ value: scaleX.displayValue,
+ onFocus: scaleX.onFocus,
+ onChange: scaleX.onChange,
+ onBlur: scaleX.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleX.scrubTo,
+ onScrubEnd: scaleX.commitScrub,
+ onReset: () =>
+ scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleX,
+ staticValue: element.transform.scaleX,
+ defaultValue: DEFAULTS.element.transform.scaleX,
+ }),
+ };
+
+ const scaleFieldPropsY = {
+ value: scaleY.displayValue,
+ onFocus: scaleY.onFocus,
+ onChange: scaleY.onChange,
+ onBlur: scaleY.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleY.scrubTo,
+ onScrubEnd: scaleY.commitScrub,
+ onReset: () =>
+ scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleY,
+ staticValue: element.transform.scaleY,
+ defaultValue: DEFAULTS.element.transform.scaleY,
+ }),
+ };
+
+ 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 = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ valueAtPlayhead: resolvedTransform.rotate,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
+ });
+
+ const hasScaleKeyframe = hasGroupKeyframeAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+
+ const toggleScaleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+ const existing = getGroupKeyframesAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+ if (existing.length > 0) {
+ editor.timeline.removeKeyframes({
+ keyframes: existing.map((ref) => ({
+ trackId,
+ elementId: element.id,
+ ...ref,
+ })),
+ });
+ return;
+ }
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleX",
+ time: localTime,
+ value: resolvedTransform.scaleX,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleY",
+ time: localTime,
+ value: resolvedTransform.scaleY,
+ },
+ ],
+ });
+ };
+
+ const scaleLockButton = (
+ <Button
+ type="button"
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setTransformScaleLocked(!isScaleLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ );
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:transform`}>
+ <SectionHeader>
+ <SectionTitle>Transform</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <div className="flex items-end gap-2">
+ {isScaleLocked ? (
+ <>
+ <SectionField
+ label="Scale"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={hasScaleKeyframe}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle scale keyframe"
+ onToggle={toggleScaleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldPropsX}
+ />
+ </SectionField>
+ {scaleLockButton}
+ </>
+ ) : (
+ <>
+ <SectionField
+ label="Width"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle width scale keyframe"
+ onToggle={scaleX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="W" {...scaleFieldPropsX} />
+ </SectionField>
+ {scaleLockButton}
+ <SectionField
+ label="Height"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle height scale keyframe"
+ onToggle={scaleY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="H" {...scaleFieldPropsY} />
+ </SectionField>
+ </>
+ )}
+ </div>
+ <SectionField
+ label="Position"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={position.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle position keyframe"
+ onToggle={position.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon="X"
+ className="flex-1"
+ value={position.x.displayValue}
+ onFocus={position.x.onFocus}
+ onChange={position.x.onChange}
+ onBlur={position.x.onBlur}
+ onScrub={position.x.scrubTo}
+ onScrubEnd={position.x.commitScrub}
+ onReset={() =>
+ position.commitX({
+ value: DEFAULTS.element.transform.position.x,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULTS.element.transform.position.x,
+ })}
+ />
+ <NumberField
+ icon="Y"
+ className="flex-1"
+ value={position.y.displayValue}
+ onFocus={position.y.onFocus}
+ onChange={position.y.onChange}
+ onBlur={position.y.onBlur}
+ onScrub={position.y.scrubTo}
+ onScrubEnd={position.y.commitScrub}
+ onReset={() =>
+ position.commitY({
+ value: DEFAULTS.element.transform.position.y,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULTS.element.transform.position.y,
+ })}
+ />
+ </div>
+ </SectionField>
+
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ 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>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx
deleted file mode 100644
index 49dd8fe5..00000000
--- a/apps/web/src/components/editor/panels/properties/video-properties.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import type {
- ImageElement,
- StickerElement,
- VideoElement,
-} from "@/types/timeline";
-import { BlendingSection, TransformSection } from "./sections";
-
-export function VideoProperties({
- element,
- trackId,
-}: {
- element: VideoElement | ImageElement | StickerElement;
- trackId: string;
-}) {
- return (
- <div className="flex h-full flex-col">
- <TransformSection
- element={element}
- trackId={trackId}
- showTopBorder={false}
- />
- <BlendingSection element={element} trackId={trackId} />
- </div>
- );
-}