diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/properties')
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" + /> + ), }; } |
