diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/properties/tabs | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/tabs')
8 files changed, 2869 insertions, 0 deletions
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/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx new file mode 100644 index 00000000..c360817c --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx @@ -0,0 +1,230 @@ +import { useEditor } from "@/hooks/use-editor"; +import { clamp } from "@/utils/math"; +import { NumberField } from "@/components/ui/number-field"; +import { OcCheckerboardIcon } from "@opencut/ui/icons"; +import { Fragment, useRef } from "react"; +import { useMenuPreview } from "@/hooks/use-menu-preview"; +import { + Section, + SectionContent, + SectionField, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +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 "../components/keyframe-toggle"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { resolveOpacityAtTime } from "@/lib/animation"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { isPropertyAtDefault } from "./transform-tab"; + +type BlendingElement = { + id: string; + opacity: number; + type: ElementType; + blendMode?: BlendMode; + startTime: number; + duration: number; + animations?: ElementAnimations; +}; + +const BLEND_MODE_GROUPS = [ + [{ value: "normal", label: "Normal" }], + [ + { value: "darken", label: "Darken" }, + { value: "multiply", label: "Multiply" }, + { value: "color-burn", label: "Color Burn" }, + ], + [ + { value: "lighten", label: "Lighten" }, + { value: "screen", label: "Screen" }, + { value: "plus-lighter", label: "Plus Lighter" }, + { value: "color-dodge", label: "Color Dodge" }, + ], + [ + { value: "overlay", label: "Overlay" }, + { value: "soft-light", label: "Soft Light" }, + { value: "hard-light", label: "Hard Light" }, + ], + [ + { value: "difference", label: "Difference" }, + { value: "exclusion", label: "Exclusion" }, + ], + [ + { value: "hue", label: "Hue" }, + { value: "saturation", label: "Saturation" }, + { value: "color", label: "Color" }, + { value: "luminosity", label: "Luminosity" }, + ], +]; + +export function BlendingTab({ + element, + trackId, +}: { + element: BlendingElement; + trackId: string; +}) { + const editor = useEditor(); + const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); + const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; + const committedBlendModeRef = useRef(blendMode); + if (!isPreviewActive) { + committedBlendModeRef.current = blendMode; + } + + const { + onPointerLeave, + onOpenChange: handleBlendModeOpenChange, + markCommitted, + } = useMenuPreview(); + + const previewBlendMode = ({ value }: { value: BlendMode }) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { blendMode: value } }, + ], + }); + + const commitBlendMode = (value: string) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { blendMode: value as BlendMode }, + }, + ], + }); + } + markCommitted(); + }; + + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedOpacity = resolveOpacityAtTime({ + baseOpacity: element.opacity, + animations: element.animations, + localTime, + }); + + const opacity = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "opacity", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOpacity * 100).toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: 0, max: 100 }) / 100; + }, + valueAtPlayhead: resolvedOpacity, + step: 0.01, + buildBaseUpdates: ({ value }) => ({ opacity: value }), + }); + + return ( + <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} + /> + } + > + <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} + > + <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/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx new file mode 100644 index 00000000..f8e2527a --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -0,0 +1,762 @@ +"use client"; + +import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import type { TextElement } from "@/lib/timeline"; +import { NumberField } from "@/components/ui/number-field"; +import { useRef } from "react"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { Button } from "@/components/ui/button"; +import { uppercase } from "@/utils/string"; +import { clamp } from "@/utils/math"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_COLOR } from "@/constants/project-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, + 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 { 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, +} 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 TextTab({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + return ( + <div className="flex flex-col"> + <ContentSection element={element} trackId={trackId} /> + <TypographySection element={element} trackId={trackId} /> + <SpacingSection element={element} trackId={trackId} /> + <BackgroundSection element={element} trackId={trackId} /> + </div> + ); +} + +function ContentSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + + const content = usePropertyDraft({ + displayValue: element.content, + parse: (input) => input, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { content: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey={`${element.id}:content`}> + <SectionHeader> + <SectionTitle>Content</SectionTitle> + </SectionHeader> + <SectionContent> + <Textarea + placeholder="Name" + value={content.displayValue} + className="min-h-20" + onFocus={content.onFocus} + onChange={content.onChange} + onBlur={content.onBlur} + /> + </SectionContent> + </Section> + ); +} + +function TypographySection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedTextColor = resolveColorAtTime({ + baseColor: element.color, + animations: element.animations, + propertyPath: "color", + localTime, + }); + + const textColor = useKeyframedColorProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "color", + localTime, + isPlayheadWithinElementRange, + resolvedColor: resolvedTextColor, + buildBaseUpdates: ({ value }) => ({ color: value }), + }); + + const fontSize = usePropertyDraft({ + displayValue: element.fontSize.toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ + value: Math.round(parsed), + min: MIN_FONT_SIZE, + max: MAX_FONT_SIZE, + }); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { fontSize: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey={`${element.id}:typography`}> + <SectionHeader> + <SectionTitle>Typography</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + <SectionField label="Font"> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value) => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }, + ], + }) + } + /> + </SectionField> + <SectionField label="Size"> + <NumberField + value={fontSize.displayValue} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={fontSize.onFocus} + onChange={fontSize.onChange} + onBlur={fontSize.onBlur} + onScrub={fontSize.scrubTo} + onScrubEnd={fontSize.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + fontSize: DEFAULTS.text.element.fontSize, + }, + }, + ], + }) + } + isDefault={element.fontSize === DEFAULTS.text.element.fontSize} + icon={<HugeiconsIcon icon={TextFontIcon} />} + /> + </SectionField> + <SectionField + label="Color" + beforeLabel={ + <KeyframeToggle + isActive={textColor.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle text color keyframe" + onToggle={textColor.toggleKeyframe} + /> + } + > + <ColorPicker + value={uppercase({ + string: resolvedTextColor.replace("#", ""), + })} + onChange={(color) => textColor.onChange({ color: `#${color}` })} + onChangeEnd={textColor.onChangeEnd} + /> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + ); +} + +function SpacingSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + + const letterSpacing = usePropertyDraft({ + displayValue: Math.round( + element.letterSpacing ?? DEFAULTS.text.letterSpacing, + ).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.round(parsed); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { letterSpacing: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const lineHeight = usePropertyDraft({ + displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) + ? null + : Math.max(0.1, Math.round(parsed * 10) / 10); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { lineHeight: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey={`${element.id}:spacing`}> + <SectionHeader> + <SectionTitle>Spacing</SectionTitle> + </SectionHeader> + <SectionContent> + <div className="flex items-start gap-2"> + <SectionField label="Letter spacing" className="w-1/2"> + <NumberField + value={letterSpacing.displayValue} + onFocus={letterSpacing.onFocus} + onChange={letterSpacing.onChange} + onBlur={letterSpacing.onBlur} + onScrub={letterSpacing.scrubTo} + onScrubEnd={letterSpacing.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { letterSpacing: DEFAULTS.text.letterSpacing }, + }, + ], + }) + } + isDefault={ + (element.letterSpacing ?? DEFAULTS.text.letterSpacing) === + DEFAULTS.text.letterSpacing + } + icon={<OcTextWidthIcon size={14} />} + /> + </SectionField> + <SectionField label="Line height" className="w-1/2"> + <NumberField + value={lineHeight.displayValue} + onFocus={lineHeight.onFocus} + onChange={lineHeight.onChange} + onBlur={lineHeight.onBlur} + onScrub={lineHeight.scrubTo} + onScrubEnd={lineHeight.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { lineHeight: DEFAULTS.text.lineHeight }, + }, + ], + }) + } + isDefault={ + (element.lineHeight ?? DEFAULTS.text.lineHeight) === + DEFAULTS.text.lineHeight + } + icon={<OcTextHeightIcon size={14} />} + /> + </SectionField> + </div> + </SectionContent> + </Section> + ); +} + +function BackgroundSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_COLOR); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedBgColor = resolveColorAtTime({ + baseColor: element.background.color, + animations: element.animations, + propertyPath: "background.color", + localTime, + }); + + const bgColor = useKeyframedColorProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.color", + localTime, + isPlayheadWithinElementRange, + resolvedColor: resolvedBgColor, + buildBaseUpdates: ({ value }) => ({ + background: { ...element.background, color: value }, + }), + }); + + const bg = element.background; + + const resolvedPaddingX = resolveNumberAtTime({ + baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }); + const resolvedPaddingY = resolveNumberAtTime({ + baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }); + const resolvedOffsetX = resolveNumberAtTime({ + baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }); + const resolvedOffsetY = resolveNumberAtTime({ + baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + }); + const resolvedCornerRadius = resolveNumberAtTime({ + baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + }); + + const paddingX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedPaddingX).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); + }, + valueAtPlayhead: resolvedPaddingX, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, paddingX: value }, + }), + }); + + const paddingY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedPaddingY).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); + }, + valueAtPlayhead: resolvedPaddingY, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, paddingY: value }, + }), + }); + + const offsetX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOffsetX).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.round(parsed); + }, + valueAtPlayhead: resolvedOffsetX, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, offsetX: value }, + }), + }); + + const offsetY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOffsetY).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.round(parsed); + }, + valueAtPlayhead: resolvedOffsetY, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, offsetY: value }, + }), + }); + + const cornerRadius = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedCornerRadius).toString(), + 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, + }); + }, + valueAtPlayhead: resolvedCornerRadius, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, cornerRadius: value }, + }), + }); + + const toggleBackgroundEnabled = () => { + const enabled = !element.background.enabled; + const color = + enabled && element.background.color === "transparent" + ? lastSelectedColor.current + : element.background.color; + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { + ...element.background, + enabled, + color, + }, + }, + }, + ], + }); + }; + + return ( + <Section + collapsible + defaultOpen={element.background.enabled} + sectionKey={`${element.id}:background`} + > + <SectionHeader + trailing={ + <Button + variant="ghost" + size="icon" + onClick={(event) => { + event.stopPropagation(); + toggleBackgroundEnabled(); + }} + > + <HugeiconsIcon + icon={element.background.enabled ? MinusSignIcon : PlusSignIcon} + strokeWidth={1} + /> + </Button> + } + > + <SectionTitle>Background</SectionTitle> + </SectionHeader> + <SectionContent + className={cn( + !element.background.enabled && "pointer-events-none opacity-50", + )} + > + <SectionFields> + <SectionField + label="Color" + beforeLabel={ + <KeyframeToggle + isActive={bgColor.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background color keyframe" + onToggle={bgColor.toggleKeyframe} + /> + } + > + <ColorPicker + value={ + !element.background.enabled || + element.background.color === "transparent" + ? lastSelectedColor.current.replace("#", "") + : resolvedBgColor.replace("#", "") + } + onChange={(color) => { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } + bgColor.onChange({ color: hexColor }); + }} + onChangeEnd={bgColor.onChangeEnd} + /> + </SectionField> + <div className="flex items-start gap-2"> + <SectionField + label="Width" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={paddingX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background width keyframe" + onToggle={paddingX.toggleKeyframe} + /> + } + > + <NumberField + icon="W" + value={paddingX.displayValue} + min={0} + onFocus={paddingX.onFocus} + onChange={paddingX.onChange} + onBlur={paddingX.onBlur} + onScrub={paddingX.scrubTo} + onScrubEnd={paddingX.commitScrub} + onReset={() => + paddingX.commitValue({ + value: DEFAULTS.text.background.paddingX, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingX, + staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, + defaultValue: DEFAULTS.text.background.paddingX, + })} + /> + </SectionField> + <SectionField + label="Height" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={paddingY.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background height keyframe" + onToggle={paddingY.toggleKeyframe} + /> + } + > + <NumberField + icon="H" + value={paddingY.displayValue} + min={0} + onFocus={paddingY.onFocus} + onChange={paddingY.onChange} + onBlur={paddingY.onBlur} + onScrub={paddingY.scrubTo} + onScrubEnd={paddingY.commitScrub} + onReset={() => + paddingY.commitValue({ + value: DEFAULTS.text.background.paddingY, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingY, + staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, + defaultValue: DEFAULTS.text.background.paddingY, + })} + /> + </SectionField> + </div> + <div className="flex items-start gap-2"> + <SectionField + label="X-offset" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={offsetX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle x-offset keyframe" + onToggle={offsetX.toggleKeyframe} + /> + } + > + <NumberField + icon="X" + value={offsetX.displayValue} + onFocus={offsetX.onFocus} + onChange={offsetX.onChange} + onBlur={offsetX.onBlur} + onScrub={offsetX.scrubTo} + onScrubEnd={offsetX.commitScrub} + onReset={() => + offsetX.commitValue({ + value: DEFAULTS.text.background.offsetX, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetX, + staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, + defaultValue: DEFAULTS.text.background.offsetX, + })} + /> + </SectionField> + <SectionField + label="Y-offset" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={offsetY.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle y-offset keyframe" + onToggle={offsetY.toggleKeyframe} + /> + } + > + <NumberField + icon="Y" + value={offsetY.displayValue} + onFocus={offsetY.onFocus} + onChange={offsetY.onChange} + onBlur={offsetY.onBlur} + onScrub={offsetY.scrubTo} + onScrubEnd={offsetY.commitScrub} + onReset={() => + offsetY.commitValue({ + value: DEFAULTS.text.background.offsetY, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetY, + staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, + defaultValue: DEFAULTS.text.background.offsetY, + })} + /> + </SectionField> + </div> + <SectionField + label="Corner radius" + beforeLabel={ + <KeyframeToggle + isActive={cornerRadius.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle corner radius keyframe" + onToggle={cornerRadius.toggleKeyframe} + /> + } + > + <NumberField + icon="R" + value={cornerRadius.displayValue} + min={CORNER_RADIUS_MIN} + max={CORNER_RADIUS_MAX} + onFocus={cornerRadius.onFocus} + onChange={cornerRadius.onChange} + onBlur={cornerRadius.onBlur} + onScrub={cornerRadius.scrubTo} + onScrubEnd={cornerRadius.commitScrub} + onReset={() => + cornerRadius.commitValue({ value: CORNER_RADIUS_MIN }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedCornerRadius, + staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + defaultValue: CORNER_RADIUS_MIN, + })} + /> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + ); +} 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> + ); +} |
