diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/properties')
4 files changed, 239 insertions, 22 deletions
diff --git a/apps/web/src/components/editor/panels/properties/components/track-audio-properties.tsx b/apps/web/src/components/editor/panels/properties/components/track-audio-properties.tsx new file mode 100644 index 00000000..4738d902 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/components/track-audio-properties.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { MusicNote03Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { NumberField } from "@/components/ui/number-field"; +import { Slider } from "@/components/ui/slider"; +import { Switch } from "@/components/ui/switch"; +import { + Section, + SectionContent, + SectionField, + SectionFields, +} from "@/components/section"; +import { useEditor } from "@/editor/use-editor"; +import type { AudioTrack, VideoTrack } from "@/timeline"; +import { clampDb, getTrackVolume } from "@/timeline/audio-state"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { + formatNumberForDisplay, + getFractionDigitsForStep, + snapToStep, +} from "@/utils/math"; + +const VOLUME_STEP = 0.1; +const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); + +function normalizeVolume(value: number): number { + return clampDb(snapToStep({ value, step: VOLUME_STEP })); +} + +function formatVolume(value: number): string { + return formatNumberForDisplay({ + value, + maxFractionDigits: VOLUME_FRACTION_DIGITS, + }); +} + +export function TrackAudioProperties({ + track, +}: { + track: AudioTrack | VideoTrack; +}) { + const editor = useEditor(); + const sourceVolume = getTrackVolume({ track }); + const [volume, setVolume] = useState(sourceVolume); + const [volumeDraft, setVolumeDraft] = useState(formatVolume(sourceVolume)); + const textEditingRef = useRef(false); + const sliderEditingRef = useRef(false); + const previousTrackIdRef = useRef(track.id); + + useEffect(() => { + const trackChanged = previousTrackIdRef.current !== track.id; + if (trackChanged) { + previousTrackIdRef.current = track.id; + textEditingRef.current = false; + sliderEditingRef.current = false; + } + if (!trackChanged && (textEditingRef.current || sliderEditingRef.current)) { + return; + } + setVolume(sourceVolume); + setVolumeDraft(formatVolume(sourceVolume)); + }, [sourceVolume, track.id]); + + const commitVolume = (nextVolume: number) => { + const normalized = normalizeVolume(nextVolume); + setVolume(normalized); + setVolumeDraft(formatVolume(normalized)); + if (normalized !== sourceVolume) { + editor.timeline.updateTrackVolume({ + trackId: track.id, + volume: normalized, + }); + } + }; + + const commitVolumeDraft = () => { + textEditingRef.current = false; + const parsed = parseFloat(volumeDraft); + if (Number.isNaN(parsed)) { + setVolume(sourceVolume); + setVolumeDraft(formatVolume(sourceVolume)); + return; + } + commitVolume(parsed); + }; + + return ( + <div className="flex min-h-full flex-col"> + <div className="flex h-12 shrink-0 items-center gap-2 border-b px-4"> + <div className="flex size-7 items-center justify-center rounded-md bg-accent"> + <HugeiconsIcon icon={MusicNote03Icon} className="size-4" /> + </div> + <div className="min-w-0"> + <div className="truncate text-sm font-medium">{track.name}</div> + <div className="text-muted-foreground text-xs">Track audio</div> + </div> + </div> + <Section sectionKey={`track-audio:${track.id}`}> + <SectionContent className="pt-4"> + <SectionFields> + <SectionField label="Volume"> + <div className="flex items-center gap-3"> + <Slider + className="min-w-0 flex-1" + aria-label={`${track.name} volume`} + value={[volume]} + min={VOLUME_DB_MIN} + max={VOLUME_DB_MAX} + step={VOLUME_STEP} + onValueChange={(values) => { + sliderEditingRef.current = true; + const nextVolume = normalizeVolume(values[0]); + setVolume(nextVolume); + setVolumeDraft(formatVolume(nextVolume)); + }} + onValueCommit={(values) => { + sliderEditingRef.current = false; + commitVolume(values[0]); + }} + /> + <NumberField + className="w-24 shrink-0" + aria-label={`${track.name} volume in decibels`} + value={volumeDraft} + min={VOLUME_DB_MIN} + max={VOLUME_DB_MAX} + step={VOLUME_STEP} + onFocus={() => { + textEditingRef.current = true; + }} + onChange={(event) => { + const nextDraft = event.currentTarget.value; + setVolumeDraft(nextDraft); + const parsed = parseFloat(nextDraft); + if (!Number.isNaN(parsed)) { + setVolume(normalizeVolume(parsed)); + } + }} + onBlur={commitVolumeDraft} + /> + </div> + </SectionField> + <SectionField label="Muted"> + <Switch + checked={track.muted} + onCheckedChange={() => { + editor.timeline.toggleTrackMute({ trackId: track.id }); + }} + /> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index d946027d..f4ce9651 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -14,13 +14,32 @@ import { usePropertiesStore } from "./stores/properties-store"; import { getPropertiesConfig } from "./registry"; import { cn } from "@/utils/ui"; import { EmptyView } from "./empty-view"; +import { TrackAudioProperties } from "./components/track-audio-properties"; export function PropertiesPanel() { const editor = useEditor(); useEditor((e) => e.scenes.getActiveSceneOrNull()); useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); - const { activeTabPerType, setActiveTab } = usePropertiesStore(); + const { activeTabPerType, setActiveTab, selectedTrackId } = + usePropertiesStore(); + const selectedTrack = selectedTrackId + ? editor.timeline.getTrackById({ trackId: selectedTrackId }) + : null; + + if ( + selectedElements.length === 0 && + selectedTrack && + (selectedTrack.type === "audio" || selectedTrack.type === "video") + ) { + return ( + <div className="panel bg-background flex h-full overflow-hidden rounded-sm border"> + <ScrollArea className="flex-1 scrollbar-hidden"> + <TrackAudioProperties key={selectedTrack.id} track={selectedTrack} /> + </ScrollArea> + </div> + ); + } if (selectedElements.length === 0) { return ( diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index 15f010f7..805098f8 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -23,7 +23,10 @@ import { DashboardSpeed02Icon, } from "@hugeicons/core-free-icons"; import { ElementParamsTab } from "./components/element-params-tab"; -import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-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"; @@ -40,6 +43,11 @@ const TRANSFORM_PARAM_KEYS = [ const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const; const AUDIO_PARAM_KEYS = ["volume", "muted"] as const; +const TRANSITION_PARAM_KEYS = [ + "transition.kind", + "transition.inDuration", + "transition.outDuration", +] as const; const TEXT_PARAM_KEYS = [ "content", "fontFamily", @@ -135,6 +143,25 @@ function buildAudioTab({ ), }; } +function buildTransitionTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "transition", + label: "Transition", + icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, + content: ({ trackId }) => ( + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={TRANSITION_PARAM_KEYS} + sectionKey="transition" + /> + ), + }; +} function buildSpeedTab({ element, @@ -202,7 +229,9 @@ function buildGraphicTab({ id: "graphic", label: "Graphic", icon: <OcShapesIcon size={16} />, - content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />, + content: ({ trackId }) => ( + <GraphicTab element={element} trackId={trackId} /> + ), }; } @@ -232,6 +261,7 @@ function getTextConfig({ buildTextTab({ element }), buildTransformTab({ element }), buildBlendingTab({ element }), + buildTransitionTab({ element }), ], }; } @@ -251,6 +281,7 @@ function getVideoConfig({ ...(showAudioTab ? [buildAudioTab({ element })] : []), buildSpeedTab({ element }), buildBlendingTab({ element }), + buildTransitionTab({ element }), buildMasksTab({ element }), ...(FEATURES.effects ? [buildClipEffectsTab({ element })] : []), ], @@ -267,6 +298,7 @@ function getImageConfig({ tabs: [ buildTransformTab({ element }), buildBlendingTab({ element }), + buildTransitionTab({ element }), buildMasksTab({ element }), ...(FEATURES.effects ? [buildClipEffectsTab({ element })] : []), ], @@ -283,6 +315,7 @@ function getStickerConfig({ tabs: [ buildTransformTab({ element }), buildBlendingTab({ element }), + buildTransitionTab({ element }), ...(FEATURES.effects ? [buildClipEffectsTab({ element })] : []), ], }; @@ -299,6 +332,7 @@ function getGraphicConfig({ buildGraphicTab({ element }), buildTransformTab({ element }), buildBlendingTab({ element }), + buildTransitionTab({ element }), buildMasksTab({ element }), ...(FEATURES.effects ? [buildClipEffectsTab({ element })] : []), ], 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 index f6304349..3303dae2 100644 --- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -1,19 +1,25 @@ -import { create } from "zustand";
-
-interface PropertiesState {
- activeTabPerType: Record<string, string>;
- setActiveTab: (args: { elementType: string; tabId: string }) => void;
- isTransformScaleLocked: boolean;
- setTransformScaleLocked: (args: { 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 }),
-}));
+import { create } from "zustand"; + +interface PropertiesState { + activeTabPerType: Record<string, string>; + setActiveTab: (args: { elementType: string; tabId: string }) => void; + isTransformScaleLocked: boolean; + setTransformScaleLocked: (args: { locked: boolean }) => void; + selectedTrackId: string | null; + selectTrack: ({ trackId }: { trackId: string }) => void; + clearTrackSelection: () => 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 }), + selectedTrackId: null, + selectTrack: ({ trackId }) => set({ selectedTrackId: trackId }), + clearTrackSelection: () => set({ selectedTrackId: null }), +})); |
