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