diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx new file mode 100644 index 00000000..1caf73eb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { 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 ?? []; + + 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={ + <> + <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> + </> + } + > + <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> + ); +} |
