From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- legacy/components/properties/audio-properties.tsx | 3 + .../properties/clip-effects-properties.tsx | 215 ++++++ .../components/properties/effect-param-field.tsx | 152 +++++ legacy/components/properties/effect-properties.tsx | 58 ++ legacy/components/properties/keyframe-toggle.tsx | 32 + legacy/components/properties/section.tsx | 262 ++++++++ legacy/components/properties/text-properties.tsx | 732 +++++++++++++++++++++ legacy/components/properties/video-properties.tsx | 25 + 8 files changed, 1479 insertions(+) create mode 100644 legacy/components/properties/audio-properties.tsx create mode 100644 legacy/components/properties/clip-effects-properties.tsx create mode 100644 legacy/components/properties/effect-param-field.tsx create mode 100644 legacy/components/properties/effect-properties.tsx create mode 100644 legacy/components/properties/keyframe-toggle.tsx create mode 100644 legacy/components/properties/section.tsx create mode 100644 legacy/components/properties/text-properties.tsx create mode 100644 legacy/components/properties/video-properties.tsx (limited to 'legacy/components') 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
Audio properties
; +} 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(null); + const [dropIndex, setDropIndex] = useState(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 ( +
+
+ + Effects +
+ + {effects.map((effect, index) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder +
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"), + )} + > + +
+ ))} +
+
+ ); +} + +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 ( +
+ + + + + } + > + + {definition.name} + + + {effect.enabled && ( + + + {definition.params.map((param) => ( + + ))} + + + )} +
+ ); +} 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 ( + + + + ); +} + +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 ( + + ); + } + + if (param.type === "boolean") { + return ( + { + onPreview(checked); + onCommit(); + }} + /> + ); + } + + if (param.type === "select") { + return ( + + ); + } + + if (param.type === "color") { + return ( + 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 ( +
+ onPreview(newValue)} + onValueCommit={onCommit} + /> + +
+ ); +} 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 ( +
+ + {definition.name} + + + + {definition.params.map((param) => ( + editor.timeline.commitPreview()} + /> + ))} + + +
+ ); +} 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 ( + + ); +} 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(); +const mountedSectionKeys = new Set(); + +interface SectionContext { + isOpen: boolean; + toggle: () => void; + collapsible: boolean; +} + +const SectionCtx = createContext(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 ( + +
+ {children} +
+
+ ); +} + +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 ? ( + + ) : null; + + const headerContent = ( + <> + {leading} +
{children}
+ {(trailing || chevronIcon) && ( +
+ {trailing} + {chevronIcon && ( + + )} +
+ )} + {actions} + + ); + + if (!isInteractive) { + return ( +
+ {headerContent} +
+ ); + } + + return ( + // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested + ); + } + + return ( + + {children} + + ); +} + +export function SectionFields({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
{children}
+ ); +} + +export function SectionField({ + label, + beforeLabel, + children, + className, +}: { + label: string; + beforeLabel?: React.ReactNode; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+ {beforeLabel} + +
+ {children} +
+ ); +} + +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 ( +
+
+
{children}
+
+
+ ); + } + + return
{children}
; +} 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 ( +
+ + + + + + +
+ ); +} + +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 ( +
+ + Content + + +