diff options
Diffstat (limited to 'legacy/components')
| -rw-r--r-- | legacy/components/properties/audio-properties.tsx | 3 | ||||
| -rw-r--r-- | legacy/components/properties/clip-effects-properties.tsx | 215 | ||||
| -rw-r--r-- | legacy/components/properties/effect-param-field.tsx | 152 | ||||
| -rw-r--r-- | legacy/components/properties/effect-properties.tsx | 58 | ||||
| -rw-r--r-- | legacy/components/properties/keyframe-toggle.tsx | 32 | ||||
| -rw-r--r-- | legacy/components/properties/section.tsx | 262 | ||||
| -rw-r--r-- | legacy/components/properties/text-properties.tsx | 732 | ||||
| -rw-r--r-- | legacy/components/properties/video-properties.tsx | 25 |
8 files changed, 1479 insertions, 0 deletions
diff --git a/legacy/components/properties/audio-properties.tsx b/legacy/components/properties/audio-properties.tsx new file mode 100644 index 00000000..2ad46497 --- /dev/null +++ b/legacy/components/properties/audio-properties.tsx @@ -0,0 +1,3 @@ +export function AudioProperties() { + return <div className="space-y-4 p-5">Audio properties</div>; +} diff --git a/legacy/components/properties/clip-effects-properties.tsx b/legacy/components/properties/clip-effects-properties.tsx new file mode 100644 index 00000000..a1324bdf --- /dev/null +++ b/legacy/components/properties/clip-effects-properties.tsx @@ -0,0 +1,215 @@ +"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/legacy/components/properties/effect-param-field.tsx b/legacy/components/properties/effect-param-field.tsx new file mode 100644 index 00000000..408d430a --- /dev/null +++ b/legacy/components/properties/effect-param-field.tsx @@ -0,0 +1,152 @@ +"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/legacy/components/properties/effect-properties.tsx b/legacy/components/properties/effect-properties.tsx new file mode 100644 index 00000000..75fc30a1 --- /dev/null +++ b/legacy/components/properties/effect-properties.tsx @@ -0,0 +1,58 @@ +"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/legacy/components/properties/keyframe-toggle.tsx b/legacy/components/properties/keyframe-toggle.tsx new file mode 100644 index 00000000..b5b8685f --- /dev/null +++ b/legacy/components/properties/keyframe-toggle.tsx @@ -0,0 +1,32 @@ +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { KeyframeIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +export function KeyframeToggle({ + isActive, + isDisabled = false, + title, + onToggle, +}: { + isActive: boolean; + isDisabled?: boolean; + title: string; + onToggle: () => void; +}) { + return ( + <Button + variant="text" + aria-pressed={isActive} + disabled={isDisabled} + title={title} + onClick={onToggle} + className="[&>svg]:size-3.5 mb-0.5" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn(isActive && "text-primary fill-primary")} + /> + </Button> + ); +} diff --git a/legacy/components/properties/section.tsx b/legacy/components/properties/section.tsx new file mode 100644 index 00000000..5989108b --- /dev/null +++ b/legacy/components/properties/section.tsx @@ -0,0 +1,262 @@ +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/legacy/components/properties/text-properties.tsx b/legacy/components/properties/text-properties.tsx new file mode 100644 index 00000000..ae6de420 --- /dev/null +++ b/legacy/components/properties/text-properties.tsx @@ -0,0 +1,732 @@ +import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import type { TextElement } from "@/types/timeline"; +import { NumberField } from "@/components/ui/number-field"; +import { useRef } from "react"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "./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, + 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 { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + TextFontIcon, + ViewIcon, + ViewOffSlashIcon, +} from "@hugeicons/core-free-icons"; +import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons"; +import { cn } from "@/utils/ui"; + +export function TextProperties({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + return ( + <div className="flex h-full 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} /> + </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="text:content" showTopBorder={false}> + <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: 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="text: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: DEFAULT_TEXT_ELEMENT.fontSize }, + }, + ], + }) + } + isDefault={element.fontSize === DEFAULT_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 ?? DEFAULT_LETTER_SPACING, + ).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 ?? DEFAULT_LINE_HEIGHT).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="text:spacing" showBottomBorder={false}> + <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: DEFAULT_LETTER_SPACING }, + }, + ], + }) + } + isDefault={ + (element.letterSpacing ?? DEFAULT_LETTER_SPACING) === + DEFAULT_LETTER_SPACING + } + 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: DEFAULT_LINE_HEIGHT }, + }, + ], + }) + } + isDefault={ + (element.lineHeight ?? DEFAULT_LINE_HEIGHT) === + DEFAULT_LINE_HEIGHT + } + 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 ?? DEFAULT_TEXT_BACKGROUND.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }); + const resolvedPaddingY = resolveNumberAtTime({ + baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }); + const resolvedOffsetX = resolveNumberAtTime({ + baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }); + const resolvedOffsetY = resolveNumberAtTime({ + baseValue: bg.offsetY ?? DEFAULT_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, + 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, + 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, + 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, + 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, + 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="text:background" + > + <SectionHeader + trailing={ + <Button + variant="ghost" + size="icon" + onClick={(event) => { + event.stopPropagation(); + toggleBackgroundEnabled(); + }} + > + <HugeiconsIcon + icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon} + /> + </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: DEFAULT_TEXT_BACKGROUND.paddingX })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingX, + staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + defaultValue: DEFAULT_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: DEFAULT_TEXT_BACKGROUND.paddingY })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingY, + staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + defaultValue: DEFAULT_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: DEFAULT_TEXT_BACKGROUND.offsetX })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetX, + staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + defaultValue: DEFAULT_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: DEFAULT_TEXT_BACKGROUND.offsetY })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetY, + staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, + defaultValue: DEFAULT_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/legacy/components/properties/video-properties.tsx b/legacy/components/properties/video-properties.tsx new file mode 100644 index 00000000..49dd8fe5 --- /dev/null +++ b/legacy/components/properties/video-properties.tsx @@ -0,0 +1,25 @@ +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> + ); +} |
