From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../properties/components/property-param-field.tsx | 2 +- .../properties/hooks/use-element-playhead.ts | 4 +- .../hooks/use-keyframed-color-property.ts | 8 +- .../hooks/use-keyframed-number-property.ts | 8 +- .../hooks/use-keyframed-param-property.ts | 10 +- .../components/editor/panels/properties/index.tsx | 4 +- .../editor/panels/properties/registry.tsx | 20 +- .../editor/panels/properties/tabs/audio-tab.tsx | 151 ---- .../editor/panels/properties/tabs/blending-tab.tsx | 230 ------ .../editor/panels/properties/tabs/effects-tab.tsx | 309 -------- .../editor/panels/properties/tabs/graphic-tab.tsx | 238 ------ .../editor/panels/properties/tabs/masks-tab.tsx | 826 --------------------- .../editor/panels/properties/tabs/speed-tab.tsx | 156 ---- .../editor/panels/properties/tabs/text-tab.tsx | 767 ------------------- .../panels/properties/tabs/transform-tab.tsx | 462 ------------ 15 files changed, 28 insertions(+), 3167 deletions(-) delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx (limited to 'apps/web/src/components/editor/panels/properties') diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx index 9cc2b02e..c9a9c22e 100644 --- a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx +++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ParamDefinition, NumberParamDefinition } from "@/lib/params"; +import type { ParamDefinition, NumberParamDefinition } from "@/params"; import { formatNumberForDisplay, getFractionDigitsForStep, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 27300784..967156b3 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,5 +1,5 @@ -import { useEditor } from "@/hooks/use-editor"; -import { getElementLocalTime } from "@/lib/animation"; +import { useEditor } from "@/editor/use-editor"; +import { getElementLocalTime } from "@/animation"; export function useElementPlayhead({ startTime, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index a42e422f..734b514f 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -1,11 +1,11 @@ -import { useEditor } from "@/hooks/use-editor"; +import { useEditor } from "@/editor/use-editor"; import { getKeyframeAtTime, hasKeyframesForPath, upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; export function useKeyframedColorProperty({ trackId, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 872d55c0..9dd39768 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -1,11 +1,11 @@ -import { useEditor } from "@/hooks/use-editor"; +import { useEditor } from "@/editor/use-editor"; import { getKeyframeAtTime, hasKeyframesForPath, upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; import { snapToStep } from "@/utils/math"; import { usePropertyDraft } from "./use-property-draft"; diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 29b290d9..b0b6f01a 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -1,6 +1,6 @@ "use client"; -import { useEditor } from "@/hooks/use-editor"; +import { useEditor } from "@/editor/use-editor"; import { buildGraphicParamPath, coerceAnimationValueForParam, @@ -9,12 +9,12 @@ import { getParamValueKind, hasKeyframesForPath, upsertPathKeyframe, -} from "@/lib/animation"; +} from "@/animation"; import type { ElementAnimations, -} from "@/lib/animation/types"; -import type { ParamDefinition } from "@/lib/params"; -import type { TimelineElement } from "@/lib/timeline"; +} from "@/animation/types"; +import type { ParamDefinition } from "@/params"; +import type { TimelineElement } from "@/timeline"; export interface KeyframedParamPropertyResult { hasAnimatedKeyframes: boolean; diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 75319ef3..52fa42b5 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -8,8 +8,8 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useEditor } from "@/hooks/use-editor"; -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { useEditor } from "@/editor/use-editor"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { usePropertiesStore } from "./stores/properties-store"; import { getPropertiesConfig } from "./registry"; import { cn } from "@/utils/ui"; diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index df6c0e52..f9e40ab3 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -11,8 +11,8 @@ import type { VideoElement, AudioElement, TimelineElement, -} from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; +} from "@/timeline"; +import type { MediaAsset } from "@/media/types"; import { HugeiconsIcon } from "@hugeicons/react"; import { TextFontIcon, @@ -22,14 +22,14 @@ import { MagicWand05Icon, DashboardSpeed02Icon, } from "@hugeicons/core-free-icons"; -import { TransformTab } from "./tabs/transform-tab"; -import { BlendingTab } from "./tabs/blending-tab"; -import { AudioTab } from "./tabs/audio-tab"; -import { TextTab } from "./tabs/text-tab"; -import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab"; -import { MasksTab } from "./tabs/masks-tab"; -import { SpeedTab } from "./tabs/speed-tab"; -import { GraphicTab } from "./tabs/graphic-tab"; +import { TransformTab } from "@/rendering/components/transform-tab"; +import { BlendingTab } from "@/rendering/components/blending-tab"; +import { AudioTab } from "@/timeline/components/audio-tab"; +import { TextTab } from "@/text/components/text-tab"; +import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab"; +import { MasksTab } from "@/masks/components/masks-tab"; +import { SpeedTab } from "@/speed/components/speed-tab"; +import { GraphicTab } from "@/graphics/components/graphic-tab"; import { OcShapesIcon } from "@/components/icons"; export type TabContentProps = { 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 deleted file mode 100644 index 5ac0fbb7..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - isNearlyEqual, - snapToStep, -} from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; -import { resolveNumberAtTime } from "@/lib/animation"; -import { useEditor } from "@/hooks/use-editor"; -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"; - -const VOLUME_STEP = 0.1; -const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - 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: formatNumberForDisplay({ - value: resolvedVolume, - fractionDigits: VOLUME_FRACTION_DIGITS, - }), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ - value: snapToStep({ value: parsed, step: VOLUME_STEP }), - min: VOLUME_DB_MIN, - max: VOLUME_DB_MAX, - }); - }, - valueAtPlayhead: resolvedVolume, - step: VOLUME_STEP, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - const isSeparated = - element.type === "video" && isSourceAudioSeparated({ element }); - - return ( - <> - {isSeparated && ( -
-

Audio has been separated.

- -
- )} -
- - Audio - - - - - } - > - } - 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" - /> - - - -
- - ); -} 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 deleted file mode 100644 index f8e5397d..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useEditor } from "@/hooks/use-editor"; -import { clamp } from "@/utils/math"; -import { NumberField } from "@/components/ui/number-field"; -import { OcCheckerboardIcon } from "@/components/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: BlendMode; label: string }[][] = [ - [{ 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: BlendMode) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { blendMode: value }, - }, - ], - }); - } - 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 ( -
- - Blending - - -
- - } - > - - } - 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" - /> - - - - -
-
-
- ); -} 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 deleted file mode 100644 index 1b94ee2d..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx +++ /dev/null @@ -1,309 +0,0 @@ -"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 ( -
-
- Effect -
- -
- ); -} - -export function ClipEffectsTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const [dragIndex, setDragIndex] = useState(null); - const [dropIndex, setDropIndex] = useState(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 ( -
-
- Effects -
- {effects.length === 0 ? ( - - ) : ( -
    - {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 ( -
  • 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", - )} - > - - editor.timeline.toggleClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - onRemove={() => - editor.timeline.removeClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - /> -
  • - ); - })} -
- )} -
- ); -} - -function EmptyView() { - const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab); - - return ( -
- -
-

No effects

-

- Add effects to this layer from the Assets panel. -

-
- -
- ); -} - -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 ( -
- - - - - ) - } - > - - {definition.name} - - - - - {definition.params.map((param) => ( -
-
- -
- -
- ))} -
-
-
- ); -} 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 deleted file mode 100644 index c61c9cd7..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx +++ /dev/null @@ -1,238 +0,0 @@ -"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 ( -
-
- - {definition.name} - - - - {shapeParams.map((param) => ( - - ))} - - -
- {hasStrokeParams && ( - - )} -
- ); -} - -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, - patch: { params: { ...element.params, strokeWidth: 0 } }, - }, - ], - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - params: { - ...element.params, - strokeWidth: lastStrokeWidth.current, - }, - }, - }, - ], - }); - } - }; - - return ( -
- { - event.stopPropagation(); - toggleStroke(); - }} - > - - - } - > - Stroke - - - - {strokeParams.map((param) => ( - - ))} - - -
- ); -} - -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 ( - - ); -} 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 deleted file mode 100644 index 090fce64..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx +++ /dev/null @@ -1,826 +0,0 @@ -"use client"; - -import type { MaskableElement } from "@/lib/timeline"; -import type { Mask, MaskType, TextMask } 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 { - ArrowExpandIcon, - Delete02Icon, - FeatherIcon, - PlusSignIcon, - RotateClockwiseIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; -import { useMemo, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { FontPicker } from "@/components/ui/font-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 { Textarea } from "@/components/ui/textarea"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - snapToStep, -} from "@/utils/math"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { usePropertyDraft } from "../hooks/use-property-draft"; -import { - OcMirrorIcon, - OcShapesIcon, - OcTextHeightIcon, - OcTextWidthIcon, -} from "@/components/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"]>; - -function isTextMask(mask: Mask): mask is TextMask { - return mask.type === "text"; -} - -export function MasksTab({ element, trackId }: MasksTabProps) { - const editor = useEditor(); - const { renderElement, previewUpdates, commit } = - useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - const maskDefs = masksRegistry.getAll(); - const tracks = useEditor( - (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, - ); - 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 - 1, - ); - - 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, - }, - ], - }); - }; - - const commitMask = ({ maskType }: { maskType: MaskType }) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - masks: [ - buildDefaultMaskInstance({ - maskType, - elementSize: elementBounds - ? { - width: elementBounds.width, - height: elementBounds.height, - } - : undefined, - }), - ], - } as Partial, - }, - ], - }); - } - 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); - }; - - return ( -
-
- Masks - - {hasMask ? ( - - - - - - - - Only one mask is supported right now. If you need more, - duplicate the clip and apply a different mask to each copy. - - - ) : ( - - - - )} - - {maskDefs.map((definition) => ( - - previewMask({ maskType: definition.type }) - } - onClick={() => commitMask({ maskType: definition.type })} - > - - {definition.name} - - ))} - - -
- - {masks.length === 0 ? ( - setIsDropdownOpen(true)} /> - ) : ( - masks.map((mask, index) => ( - - previewMaskParam({ index, key: paramKey }) - } - onCommit={commit} - /> - )) - )} -
- ); -} - -function MaskItem({ - trackId, - elementId, - mask, - previewParam, - onCommit, -}: MaskItemProps) { - const editor = useEditor(); - const definition = masksRegistry.get(mask.type); - - return ( -
- - - - - } - > -
- - - {definition.name} - -
-
- - - -
- ); -} - -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 ( - - {isTextMask(mask) ? ( - - ) : null} - {definition.features.hasPosition && - "centerX" in mask.params && - "centerY" in mask.params && ( - -
- - -
-
- )} - - {definition.features.sizeMode === "width-height" && - "width" in mask.params && - "height" in mask.params && ( - -
- - -
-
- )} - - {definition.features.sizeMode === "height-only" && - "height" in mask.params && ( - - - - )} - - {definition.features.sizeMode === "width-only" && - "width" in mask.params && ( - - - - )} - - {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( - - : "S" - } - param={getNumberParamDefinition({ - definition, - key: "scale", - })} - value={getMaskNumber({ - params: mask.params, - key: "scale", - })} - onPreview={previewNumberParam("scale")} - onCommit={onCommit} - /> - - )} - - {definition.features.hasRotation && "rotation" in mask.params && ( - - } - param={getNumberParamDefinition({ - definition, - key: "rotation", - })} - value={getMaskNumber({ - params: mask.params, - key: "rotation", - })} - onPreview={previewNumberParam("rotation")} - onCommit={onCommit} - /> - - )} - - - } - param={featherParam} - value={getMaskNumber({ - params: mask.params, - key: "feather", - })} - onPreview={previewNumberParam("feather")} - onCommit={onCommit} - /> - - - -
-
- - previewStrokeColor(`#${color}`)} - onChangeEnd={(color) => { - previewStrokeColor(`#${color}`); - onCommit(); - }} - /> -
- {strokeAlignParam ? ( - - ) : null} -
-
-
- ); -} - -const LETTER_SPACING_PARAM: NumberParamDefinition = { - key: "letterSpacing", - label: "Letter spacing", - type: "number", - default: 0, - min: -100, - max: 500, - step: 1, -}; - -const LINE_HEIGHT_PARAM: NumberParamDefinition = { - key: "lineHeight", - label: "Line height", - type: "number", - default: 1.2, - min: 0.1, - max: 10, - step: 0.1, -}; - -function TextMaskFields({ - mask, - previewParam, - onCommit, - fontSizeParam, -}: { - mask: TextMask; - previewParam: PreviewParamHandler; - onCommit: () => void; - fontSizeParam: NumberParamDefinition; -}) { - const content = usePropertyDraft({ - displayValue: mask.params.content, - parse: (input) => input, - onPreview: (value) => previewParam("content")(value), - onCommit, - }); - - const previewNumberParam = (key: string) => (value: number) => - previewParam(key)(value); - - return ( - <> - -