summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx154
-rw-r--r--apps/web/src/components/editor/panels/properties/components/property-param-field.tsx38
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts96
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts175
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts32
-rw-r--r--apps/web/src/components/editor/panels/properties/registry.tsx67
6 files changed, 263 insertions, 299 deletions
diff --git a/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx
new file mode 100644
index 00000000..2befcef4
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { resolveAnimationPathValueAtTime } from "@/animation";
+import { Section, SectionContent, SectionFields } from "@/components/section";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import { useKeyframedParamProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
+import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
+import type { ParamValue, ParamValues } from "@/params";
+import {
+ getElementParams,
+ readElementParamValue,
+ writeElementParamValue,
+ type ElementParamDefinition,
+} from "@/params/registry";
+import type { TimelineElement } from "@/timeline";
+import type { MediaTime } from "@/wasm";
+
+export function ElementParamsTab({
+ element,
+ trackId,
+ paramKeys,
+ sectionKey,
+}: {
+ element: TimelineElement;
+ trackId: string;
+ paramKeys?: readonly string[];
+ sectionKey: string;
+}) {
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const params = getElementParams({ element }).filter(
+ (param) => !paramKeys || paramKeys.includes(param.key),
+ );
+ const baseValues = buildValues({ element, params });
+
+ return (
+ <Section sectionKey={`${element.id}:${sectionKey}`}>
+ <SectionContent className="pt-4">
+ <SectionFields>
+ {params
+ .filter((param) => isVisible({ param, values: baseValues }))
+ .map((param) => (
+ <ElementParamField
+ key={param.key}
+ element={element}
+ trackId={trackId}
+ param={param}
+ baseValue={baseValues[param.key] ?? param.default}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function ElementParamField({
+ element,
+ trackId,
+ param,
+ baseValue,
+ localTime,
+ isPlayheadWithinElementRange,
+}: {
+ element: TimelineElement;
+ trackId: string;
+ param: ElementParamDefinition;
+ baseValue: ParamValue;
+ localTime: MediaTime;
+ isPlayheadWithinElementRange: boolean;
+}) {
+ const resolvedValue = resolveAnimationPathValueAtTime({
+ animations: element.animations,
+ propertyPath: param.key,
+ localTime,
+ fallbackValue: baseValue,
+ });
+ const animatedParam = useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: param.key,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ buildBaseUpdates: ({ value }) =>
+ writeElementParamValue({ element, param, value }),
+ });
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedValue}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={
+ param.keyframable === false
+ ? undefined
+ : {
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }
+ }
+ />
+ );
+}
+
+function buildValues({
+ element,
+ params,
+}: {
+ element: TimelineElement;
+ params: readonly ElementParamDefinition[];
+}): ParamValues {
+ const values: ParamValues = {};
+ for (const param of params) {
+ const value = readElementParamValue({ element, param });
+ if (value !== null) {
+ values[param.key] = value;
+ }
+ }
+ return values;
+}
+
+function isVisible({
+ param,
+ values,
+}: {
+ param: ElementParamDefinition;
+ values: ParamValues;
+}): boolean {
+ return (param.dependencies ?? []).every((dependency) =>
+ areParamValuesEqual({
+ left: values[dependency.param],
+ right: dependency.equals,
+ }),
+ );
+}
+
+function areParamValuesEqual({
+ left,
+ right,
+}: {
+ left: ParamValue | undefined;
+ right: ParamValue;
+}): boolean {
+ return left === right;
+}
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
index c9a9c22e..66225903 100644
--- 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
@@ -1,6 +1,10 @@
"use client";
-import type { ParamDefinition, NumberParamDefinition } from "@/params";
+import type {
+ ParamDefinition,
+ NumberParamDefinition,
+ ParamValue,
+} from "@/params";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
@@ -19,6 +23,7 @@ import {
} from "@/components/ui/select";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { KeyframeToggle } from "./keyframe-toggle";
+import { Textarea } from "@/components/ui/textarea";
export function PropertyParamField({
param,
@@ -28,8 +33,8 @@ export function PropertyParamField({
keyframe,
}: {
param: ParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
+ value: ParamValue;
+ onPreview: (value: ParamValue) => void;
onCommit: () => void;
keyframe?: {
isActive: boolean;
@@ -41,7 +46,7 @@ export function PropertyParamField({
<SectionField
label={param.label}
beforeLabel={
- keyframe ? (
+ keyframe && param.keyframable !== false ? (
<KeyframeToggle
isActive={keyframe.isActive}
isDisabled={keyframe.isDisabled}
@@ -68,8 +73,8 @@ function ParamInput({
onCommit,
}: {
param: ParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
+ value: ParamValue;
+ onPreview: (value: ParamValue) => void;
onCommit: () => void;
}) {
if (param.type === "number") {
@@ -131,6 +136,27 @@ function ParamInput({
);
}
+ if (param.type === "text") {
+ return (
+ <Textarea
+ value={String(value)}
+ onChange={(event) => onPreview(event.currentTarget.value)}
+ onBlur={onCommit}
+ />
+ );
+ }
+
+ if (param.type === "font") {
+ return (
+ <input
+ className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none"
+ value={String(value)}
+ onChange={(event) => onPreview(event.currentTarget.value)}
+ onBlur={onCommit}
+ />
+ );
+ }
+
return null;
}
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
deleted file mode 100644
index 91d29fd7..00000000
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
- upsertElementKeyframe,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import type { MediaTime } from "@/wasm";
-
-export function useKeyframedColorProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor,
- buildBaseUpdates,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: MediaTime;
- isPlayheadWithinElementRange: boolean;
- resolvedColor: string;
- buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
-}) {
- const editor = useEditor();
-
- const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const onChange = ({ color }: { color: string }) => {
- if (shouldUseAnimatedChannel) {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- animations: upsertElementKeyframe({
- animations,
- propertyPath,
- time: localTime,
- value: color,
- }),
- },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
- });
- };
-
- const onChangeEnd = () => 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: resolvedColor },
- ],
- });
- };
-
- return {
- isKeyframedAtTime,
- hasAnimatedKeyframes,
- keyframeIdAtTime,
- onChange,
- onChangeEnd,
- toggleKeyframe,
- };
-}
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
deleted file mode 100644
index 610c2253..00000000
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
- upsertElementKeyframe,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import { snapToStep } from "@/utils/math";
-import { usePropertyDraft } from "./use-property-draft";
-import type { MediaTime } from "@/wasm";
-
-export function useKeyframedNumberProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- displayValue,
- parse,
- valueAtPlayhead,
- step,
- buildBaseUpdates,
- buildAdditionalKeyframes,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: MediaTime;
- isPlayheadWithinElementRange: boolean;
- displayValue: string;
- parse: (input: string) => number | null;
- valueAtPlayhead: number;
- step?: number;
- buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
- buildAdditionalKeyframes?: ({
- value,
- }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
-}) {
- const editor = useEditor();
- const snapValue = (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 previewValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- const updatedAnimations = [
- { propertyPath, value: nextValue },
- ...additionalKeyframes,
- ].reduce(
- (currentAnimations, keyframe) =>
- upsertElementKeyframe({
- animations: currentAnimations,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- }),
- animations,
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: { animations: updatedAnimations },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- const propertyDraft = usePropertyDraft({
- displayValue,
- parse: (input) => {
- const parsedValue = parse(input);
- return parsedValue === null ? null : snapValue(parsedValue);
- },
- onPreview: (value) => previewValue({ value }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- keyframeId: keyframeIdAtTime,
- },
- ],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- time: localTime,
- value: snapValue(valueAtPlayhead),
- },
- ],
- });
- };
-
- const commitValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: nextValue },
- ...additionalKeyframes.map((keyframe) => ({
- trackId,
- elementId,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- })),
- ],
- });
- return;
- }
-
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId,
- patch: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- return {
- ...propertyDraft,
- hasAnimatedKeyframes,
- isKeyframedAtTime,
- keyframeIdAtTime,
- toggleKeyframe,
- commitValue,
- };
-}
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
index 4f4b9cf8..dc1ad9ca 100644
--- 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
@@ -8,14 +8,15 @@ import {
upsertPathKeyframe,
} from "@/animation";
import type {
+ AnimationPath,
ElementAnimations,
} from "@/animation/types";
import {
- coerceAnimationParamValue,
- getAnimationParamDefaultInterpolation,
- getAnimationParamValueKind,
-} from "@/animation/animated-params";
-import type { ParamDefinition } from "@/params";
+ coerceParamValue,
+ getParamDefaultInterpolation,
+ getParamValueKind,
+ type ParamDefinition,
+} from "@/params";
import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
@@ -33,6 +34,7 @@ export function useKeyframedParamProperty({
trackId,
elementId,
animations,
+ propertyPath,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
@@ -42,6 +44,7 @@ export function useKeyframedParamProperty({
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
+ propertyPath?: AnimationPath;
localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
@@ -52,15 +55,16 @@ export function useKeyframedParamProperty({
}) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult {
const editor = useEditor();
- const propertyPath = buildGraphicParamPath({ paramKey: param.key });
+ const resolvedPropertyPath =
+ propertyPath ?? buildGraphicParamPath({ paramKey: param.key });
const hasAnimatedKeyframes = hasKeyframesForPath({
animations,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
});
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({
animations,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
time: localTime,
})
: null;
@@ -79,15 +83,15 @@ export function useKeyframedParamProperty({
updates: {
animations: upsertPathKeyframe({
animations,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
time: localTime,
value,
- kind: getAnimationParamValueKind({ param }),
- defaultInterpolation: getAnimationParamDefaultInterpolation({
+ kind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({
param,
}),
coerceValue: ({ value: nextValue }) =>
- coerceAnimationParamValue({
+ coerceParamValue({
param,
value: nextValue,
}),
@@ -121,7 +125,7 @@ export function useKeyframedParamProperty({
{
trackId,
elementId,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
keyframeId: keyframeIdAtTime,
},
],
@@ -134,7 +138,7 @@ export function useKeyframedParamProperty({
{
trackId,
elementId,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
time: localTime,
value: resolvedValue,
},
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx
index f9e40ab3..1eda6575 100644
--- a/apps/web/src/components/editor/panels/properties/registry.tsx
+++ b/apps/web/src/components/editor/panels/properties/registry.tsx
@@ -22,16 +22,43 @@ import {
MagicWand05Icon,
DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons";
-import { TransformTab } from "@/rendering/components/transform-tab";
-import { BlendingTab } from "@/rendering/components/blending-tab";
-import { AudioTab } from "@/timeline/components/audio-tab";
-import { TextTab } from "@/text/components/text-tab";
+import { ElementParamsTab } from "./components/element-params-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
import { MasksTab } from "@/masks/components/masks-tab";
import { SpeedTab } from "@/speed/components/speed-tab";
import { GraphicTab } from "@/graphics/components/graphic-tab";
import { OcShapesIcon } from "@/components/icons";
+const TRANSFORM_PARAM_KEYS = [
+ "transform.positionX",
+ "transform.positionY",
+ "transform.scaleX",
+ "transform.scaleY",
+ "transform.rotate",
+] as const;
+
+const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const;
+const AUDIO_PARAM_KEYS = ["volume", "muted"] as const;
+const TEXT_PARAM_KEYS = [
+ "content",
+ "fontFamily",
+ "fontSize",
+ "color",
+ "textAlign",
+ "fontWeight",
+ "fontStyle",
+ "textDecoration",
+ "letterSpacing",
+ "lineHeight",
+ "background.enabled",
+ "background.color",
+ "background.cornerRadius",
+ "background.paddingX",
+ "background.paddingY",
+ "background.offsetX",
+ "background.offsetY",
+] as const;
+
export type TabContentProps = {
trackId: string;
};
@@ -58,7 +85,12 @@ function buildTransformTab({
label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => (
- <TransformTab element={element} trackId={trackId} />
+ <ElementParamsTab
+ element={element}
+ trackId={trackId}
+ paramKeys={TRANSFORM_PARAM_KEYS}
+ sectionKey="transform"
+ />
),
};
}
@@ -73,7 +105,12 @@ function buildBlendingTab({
label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => (
- <BlendingTab element={element} trackId={trackId} />
+ <ElementParamsTab
+ element={element}
+ trackId={trackId}
+ paramKeys={BLENDING_PARAM_KEYS}
+ sectionKey="blending"
+ />
),
};
}
@@ -87,7 +124,14 @@ function buildAudioTab({
id: "audio",
label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
- content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
+ content: ({ trackId }) => (
+ <ElementParamsTab
+ element={element}
+ trackId={trackId}
+ paramKeys={AUDIO_PARAM_KEYS}
+ sectionKey="audio"
+ />
+ ),
};
}
@@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
id: "text",
label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
- content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
+ content: ({ trackId }) => (
+ <ElementParamsTab
+ element={element}
+ trackId={trackId}
+ paramKeys={TEXT_PARAM_KEYS}
+ sectionKey="text"
+ />
+ ),
};
}