diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/components/editor/panels | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/components/editor/panels')
67 files changed, 182 insertions, 13567 deletions
diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx new file mode 100644 index 00000000..1539d305 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx @@ -0,0 +1,124 @@ +import type { ElementType } from "react"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { + ArrowRightDoubleIcon, + ClosedCaptionIcon, + Folder03Icon, + Happy01Icon, + HeadphonesIcon, + MagicWand05Icon, + TextIcon, + Settings01Icon, + SlidersHorizontalIcon, + ColorsIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; + +export const TAB_KEYS = [ + "media", + "sounds", + "text", + "stickers", + "effects", + "transitions", + "captions", + "adjustment", + "settings", +] as const; + +export type Tab = (typeof TAB_KEYS)[number]; + +const createHugeiconsIcon = + ({ icon }: { icon: IconSvgElement }) => + ({ className }: { className?: string }) => ( + <HugeiconsIcon icon={icon} className={className} /> + ); + +export const tabs = { + media: { + icon: createHugeiconsIcon({ icon: Folder03Icon }), + label: "Media", + }, + sounds: { + icon: createHugeiconsIcon({ icon: HeadphonesIcon }), + label: "Sounds", + }, + text: { + icon: createHugeiconsIcon({ icon: TextIcon }), + label: "Text", + }, + stickers: { + icon: createHugeiconsIcon({ icon: Happy01Icon }), + label: "Stickers", + }, + effects: { + icon: createHugeiconsIcon({ icon: MagicWand05Icon }), + label: "Effects", + }, + transitions: { + icon: createHugeiconsIcon({ icon: ArrowRightDoubleIcon }), + label: "Transitions", + }, + captions: { + icon: createHugeiconsIcon({ icon: ClosedCaptionIcon }), + label: "Captions", + }, + adjustment: { + icon: createHugeiconsIcon({ icon: SlidersHorizontalIcon }), + label: "Adjustment", + }, + settings: { + icon: createHugeiconsIcon({ icon: Settings01Icon }), + label: "Settings", + }, +} satisfies Record< + Tab, + { icon: ElementType<{ className?: string }>; label: string } +>; + +export type MediaViewMode = "grid" | "list"; +export type MediaSortKey = "name" | "type" | "duration" | "size"; +export type MediaSortOrder = "asc" | "desc"; + +interface AssetsPanelStore { + activeTab: Tab; + setActiveTab: (tab: Tab) => void; + highlightMediaId: string | null; + requestRevealMedia: (mediaId: string) => void; + clearHighlight: () => void; + + /* Media */ + mediaViewMode: MediaViewMode; + setMediaViewMode: (mode: MediaViewMode) => void; + mediaSortBy: MediaSortKey; + mediaSortOrder: MediaSortOrder; + setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void; +} + +export const useAssetsPanelStore = create<AssetsPanelStore>()( + persist( + (set) => ({ + activeTab: "media", + setActiveTab: (tab) => set({ activeTab: tab }), + highlightMediaId: null, + requestRevealMedia: (mediaId) => + set({ activeTab: "media", highlightMediaId: mediaId }), + clearHighlight: () => set({ highlightMediaId: null }), + mediaViewMode: "grid", + setMediaViewMode: (mode) => set({ mediaViewMode: mode }), + mediaSortBy: "name", + mediaSortOrder: "asc", + setMediaSort: (key, order) => + set({ mediaSortBy: key, mediaSortOrder: order }), + }), + { + name: "assets-panel", + partialize: (state) => ({ + mediaViewMode: state.mediaViewMode, + mediaSortBy: state.mediaSortBy, + mediaSortOrder: state.mediaSortOrder, + }), + }, + ), +); diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 166c81db..2f8bc64a 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -10,9 +10,9 @@ import { TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { useEditor } from "@/hooks/use-editor";
-import { clearDragData, setDragData } from "@/lib/drag-data";
-import type { TimelineDragData } from "@/lib/timeline/drag";
+import { useEditor } from "@/editor/use-editor";
+import { clearDragData, setDragData } from "@/timeline/drag-data";
+import type { TimelineDragData } from "@/timeline/drag";
import { cn } from "@/utils/ui";
export interface DraggableItemProps {
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index 1cdbf295..bae73299 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -1,15 +1,15 @@ "use client"; import { Separator } from "@/components/ui/separator"; -import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; +import { type Tab, useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store"; import { TabBar } from "./tabbar"; -import { Captions } from "./views/captions"; +import { Captions } from "@/subtitles/components/assets-view"; import { MediaView } from "./views/assets"; import { SettingsView } from "./views/settings"; -import { SoundsView } from "./views/sounds"; -import { StickersView } from "./views/stickers"; -import { TextView } from "./views/text"; -import { EffectsView } from "./views/effects"; +import { SoundsView } from "@/sounds/components/assets-view"; +import { StickersView } from "@/stickers/components/assets-view"; +import { TextView } from "@/text/components/assets-view"; +import { EffectsView } from "@/effects/components/assets-view"; export function AssetsPanel() { const { activeTab } = useAssetsPanelStore(); diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index da53bed0..3b084c0d 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -12,7 +12,7 @@ import { TAB_KEYS,
tabs,
useAssetsPanelStore,
-} from "@/stores/assets-panel-store";
+} from "@/components/editor/panels/assets/assets-panel-store";
export function TabBar() {
const { activeTab, setActiveTab } = useAssetsPanelStore();
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 33b47c67..4afd22b7 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -25,28 +25,28 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { useEditor } from "@/hooks/use-editor"; -import { useFileUpload } from "@/hooks/use-file-upload"; -import { invokeAction } from "@/lib/actions"; -import { processMediaAssets } from "@/lib/media/processing"; -import { showMediaUploadToast } from "@/lib/media/upload-toast"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/use-editor"; +import { useFileUpload } from "@/media/use-file-upload"; +import { invokeAction } from "@/actions"; +import { processMediaAssets } from "@/media/processing"; +import { showMediaUploadToast } from "@/media/upload-toast"; import { SelectableItem, SelectableSurface, useSelection, useSelectionScope, -} from "@/lib/selection"; -import { buildElementFromMedia } from "@/lib/timeline/element-utils"; +} from "@/selection"; +import { buildElementFromMedia } from "@/timeline/element-utils"; import { type MediaSortKey, type MediaSortOrder, type MediaViewMode, useAssetsPanelStore, -} from "@/stores/assets-panel-store"; -import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; +} from "@/components/editor/panels/assets/assets-panel-store"; +import { MASKABLE_ELEMENT_TYPES } from "@/timeline"; +import type { MediaAsset } from "@/media/types"; import { cn } from "@/utils/ui"; import { CloudUploadIcon, diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx deleted file mode 100644 index 040df749..00000000 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useReducer, useRef, useState } from "react"; -import { extractTimelineAudio } from "@/lib/media/mediabunny"; -import { useEditor } from "@/hooks/use-editor"; -import { TRANSCRIPTION_DIAGNOSTICS_SCOPE } from "@/lib/transcription/diagnostics"; -import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE } from "@/lib/transcription/audio"; -import { TRANSCRIPTION_LANGUAGES } from "@/lib/transcription/supported-languages"; -import type { - CaptionChunk, - TranscriptionLanguage, - TranscriptionProgress, -} from "@/lib/transcription/types"; -import { transcriptionService } from "@/services/transcription/service"; -import { decodeAudioToFloat32 } from "@/lib/media/audio"; -import { buildCaptionChunks } from "@/lib/transcription/caption"; -import { insertCaptionChunksAsTextTrack } from "@/lib/subtitles/insert"; -import { parseSubtitleFile } from "@/lib/subtitles/parse"; -import { Spinner } from "@/components/ui/spinner"; -import { - Section, - SectionContent, - SectionField, - SectionFields, -} from "@/components/section"; -import { AlertCircleIcon, CloudUploadIcon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import type { DiagnosticSeverity } from "@/lib/diagnostics/types"; - -const DIAGNOSTIC_BUTTON_VARIANT: Record< - DiagnosticSeverity, - "caution" | "destructive-foreground" -> = { - caution: "caution", - error: "destructive-foreground", -}; - -type ProcessingState = - | { status: "idle"; error: string | null; warnings: string[] } - | { status: "processing"; step: string }; - -type ProcessingAction = - | { type: "start"; step: string } - | { type: "update_step"; step: string } - | { type: "succeed"; warnings: string[] } - | { type: "fail"; error: string }; - -const IDLE_STATE: ProcessingState = { - status: "idle", - error: null, - warnings: [], -}; - -function processingReducer( - state: ProcessingState, - action: ProcessingAction, -): ProcessingState { - switch (action.type) { - case "start": - return { status: "processing", step: action.step }; - case "update_step": - if (state.status !== "processing") return state; - return { status: "processing", step: action.step }; - case "succeed": - return { status: "idle", error: null, warnings: action.warnings }; - case "fail": - return { status: "idle", error: action.error, warnings: [] }; - } -} - -export function Captions() { - const [selectedLanguage, setSelectedLanguage] = - useState<TranscriptionLanguage>("auto"); - const [processing, dispatch] = useReducer(processingReducer, IDLE_STATE); - const containerRef = useRef<HTMLDivElement>(null); - const fileInputRef = useRef<HTMLInputElement>(null); - const editor = useEditor(); - - const isProcessing = processing.status === "processing"; - - const activeDiagnostics = useEditor((e) => - e.diagnostics.getActive({ scope: TRANSCRIPTION_DIAGNOSTICS_SCOPE }), - ); - - const handleProgress = (progress: TranscriptionProgress) => { - if (progress.status === "loading-model") { - dispatch({ - type: "update_step", - step: `Loading model ${Math.round(progress.progress)}%`, - }); - } else if (progress.status === "transcribing") { - dispatch({ type: "update_step", step: "Transcribing..." }); - } - }; - - const insertCaptions = ({ - captions, - }: { - captions: CaptionChunk[]; - }): boolean => { - const trackId = insertCaptionChunksAsTextTrack({ editor, captions }); - return trackId !== null; - }; - - const handleGenerateTranscript = async () => { - dispatch({ type: "start", step: "Extracting audio..." }); - try { - const audioBlob = await extractTimelineAudio({ - tracks: editor.scenes.getActiveScene().tracks, - mediaAssets: editor.media.getAssets(), - totalDuration: editor.timeline.getTotalDuration(), - }); - - dispatch({ type: "update_step", step: "Preparing audio..." }); - const { samples } = await decodeAudioToFloat32({ - audioBlob, - sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE, - }); - - const result = await transcriptionService.transcribe({ - audioData: samples, - language: selectedLanguage === "auto" ? undefined : selectedLanguage, - onProgress: handleProgress, - }); - - dispatch({ type: "update_step", step: "Generating captions..." }); - const captionChunks = buildCaptionChunks({ segments: result.segments }); - - if (!insertCaptions({ captions: captionChunks })) { - dispatch({ type: "fail", error: "No captions were generated" }); - return; - } - - dispatch({ type: "succeed", warnings: [] }); - } catch (error) { - console.error("Transcription failed:", error); - dispatch({ - type: "fail", - error: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }); - } - }; - - const handleImportClick = () => { - fileInputRef.current?.click(); - }; - - const handleImportFile = async ({ file }: { file: File }) => { - dispatch({ type: "start", step: "Reading subtitle file..." }); - try { - const input = await file.text(); - const result = parseSubtitleFile({ - fileName: file.name, - input, - }); - - if (result.captions.length === 0) { - dispatch({ - type: "fail", - error: "No valid subtitle cues were found in the subtitle file", - }); - return; - } - - dispatch({ type: "update_step", step: "Importing subtitles..." }); - - if (!insertCaptions({ captions: result.captions })) { - dispatch({ type: "fail", error: "No captions were generated" }); - return; - } - - const nextWarnings = [...result.warnings]; - if (result.skippedCueCount > 0) { - nextWarnings.unshift( - `Imported ${result.captions.length} subtitle cue(s) and skipped ${result.skippedCueCount} malformed cue(s).`, - ); - } - - dispatch({ type: "succeed", warnings: nextWarnings }); - } catch (error) { - console.error("Subtitle import failed:", error); - dispatch({ - type: "fail", - error: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }); - } - }; - - const handleFileChange = async ({ - event, - }: { - event: React.ChangeEvent<HTMLInputElement>; - }) => { - const file = event.target.files?.[0]; - if (event.target) { - event.target.value = ""; - } - if (!file) return; - - await handleImportFile({ file }); - }; - - const handleLanguageChange = ({ value }: { value: string }) => { - if (value === "auto") { - setSelectedLanguage("auto"); - return; - } - - const matchedLanguage = TRANSCRIPTION_LANGUAGES.find( - (language) => language.code === value, - ); - if (!matchedLanguage) return; - setSelectedLanguage(matchedLanguage.code); - }; - - const error = processing.status === "idle" ? processing.error : null; - const warnings = processing.status === "idle" ? processing.warnings : []; - - return ( - <PanelView - title="Captions" - contentClassName="px-0 flex flex-col h-full" - actions={ - <TooltipProvider> - <div className="flex items-center gap-1.5"> - {!isProcessing && - activeDiagnostics.map((diagnostic) => ( - <Tooltip key={diagnostic.id}> - <TooltipTrigger asChild> - <Button - variant={DIAGNOSTIC_BUTTON_VARIANT[diagnostic.severity]} - size="icon" - aria-label={diagnostic.message} - > - <HugeiconsIcon icon={AlertCircleIcon} size={16} /> - </Button> - </TooltipTrigger> - <TooltipContent>{diagnostic.message}</TooltipContent> - </Tooltip> - ))} - <Button - type="button" - variant="outline" - size="sm" - onClick={handleImportClick} - disabled={isProcessing} - className="items-center justify-center gap-1.5" - > - <HugeiconsIcon icon={CloudUploadIcon} /> - Import - </Button> - </div> - </TooltipProvider> - } - ref={containerRef} - > - <input - ref={fileInputRef} - type="file" - accept=".srt,.ass" - className="hidden" - onChange={(event) => void handleFileChange({ event })} - /> - <Section - showTopBorder={false} - showBottomBorder={false} - className="flex-1" - > - <SectionContent className="flex flex-col gap-4 h-full pt-1"> - <SectionFields> - <SectionField label="Language"> - <Select - value={selectedLanguage} - onValueChange={(value) => handleLanguageChange({ value })} - > - <SelectTrigger> - <SelectValue placeholder="Select a language" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="auto">Auto detect</SelectItem> - {TRANSCRIPTION_LANGUAGES.map((language) => ( - <SelectItem key={language.code} value={language.code}> - {language.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </SectionField> - </SectionFields> - - <Button - type="button" - className="mt-auto w-full" - onClick={handleGenerateTranscript} - disabled={isProcessing || activeDiagnostics.length > 0} - > - {isProcessing && <Spinner className="mr-1" />} - {isProcessing ? processing.step : "Generate transcript"} - </Button> - {error && ( - <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3"> - <p className="text-destructive text-sm">{error}</p> - </div> - )} - {warnings.length > 0 && ( - <div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3"> - <ul className="space-y-1 text-sm text-amber-700"> - {warnings.map((warning) => ( - <li key={warning}>{warning}</li> - ))} - </ul> - </div> - )} - </SectionContent> - </Section> - </PanelView> - ); -} diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx deleted file mode 100644 index 74128df6..00000000 --- a/apps/web/src/components/editor/panels/assets/views/effects.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { useEffect, useRef, useCallback } from "react"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects"; -import { effectPreviewService } from "@/services/renderer/effect-preview"; -import { useEditor } from "@/hooks/use-editor"; -import { buildEffectElement } from "@/lib/timeline/element-utils"; -import type { EffectDefinition } from "@/lib/effects/types"; - -export function EffectsView() { - const effects = effectsRegistry.getAll(); - - return ( - <PanelView title="Effects"> - <EffectsGrid effects={effects} /> - </PanelView> - ); -} - -function EffectsGrid({ effects }: { effects: EffectDefinition[] }) { - return ( - <div - className="grid gap-2" - style={{ gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))" }} - > - {effects.map((effect) => ( - <EffectItem key={effect.type} effect={effect} /> - ))} - </div> - ); -} - -function EffectPreviewCanvas({ effectType }: { effectType: string }) { - const canvasRef = useRef<HTMLCanvasElement>(null); - - useEffect(() => { - const render = () => { - if (canvasRef.current) { - effectPreviewService.renderPreview({ - effectType, - params: {}, - targetCanvas: canvasRef.current, - }); - } - }; - - render(); - return effectPreviewService.onPreviewImageReady({ callback: render }); - }, [effectType]); - - return <canvas ref={canvasRef} className="size-full" />; -} - -function EffectItem({ effect }: { effect: EffectDefinition }) { - const editor = useEditor(); - - const handleAddToTimeline = useCallback(() => { - const currentTime = editor.playback.getCurrentTime(); - const element = buildEffectElement({ - effectType: effect.type, - startTime: currentTime, - }); - - editor.timeline.insertElement({ - placement: { mode: "auto", trackType: "effect" }, - element, - }); - }, [editor, effect.type]); - - const preview = <EffectPreviewCanvas effectType={effect.type} />; - - return ( - <DraggableItem - name={effect.name} - preview={preview} - dragData={{ - id: effect.type, - name: effect.name, - type: "effect", - effectType: effect.type, - targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES, - }} - onAddToTimeline={handleAddToTimeline} - aspectRatio={1} - isRounded - variant="card" - containerClassName="w-full" - /> - ); -} diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx index 099e335a..9ce34ebb 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx @@ -12,12 +12,12 @@ import { Popover, PopoverTrigger } from "@/components/ui/popover"; import { BACKGROUND_BLUR_INTENSITY_PRESETS, DEFAULT_BACKGROUND_BLUR_INTENSITY, -} from "@/lib/background/blur"; -import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color"; +} from "@/background/blur"; +import { DEFAULT_BACKGROUND_COLOR } from "@/background/color"; import { patternCraftGradients } from "@/data/colors/pattern-craft"; import { colors } from "@/data/colors/solid"; import { syntaxUIGradients } from "@/data/colors/syntax-ui"; -import { useEditor } from "@/hooks/use-editor"; +import { useEditor } from "@/editor/use-editor"; import { effectPreviewService } from "@/services/renderer/effect-preview"; import { cn } from "@/utils/ui"; diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx index 4d87493a..bf3a1c16 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -9,9 +9,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { FPS_PRESETS } from "@/lib/fps/presets"; -import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils"; -import { useEditor } from "@/hooks/use-editor"; +import { FPS_PRESETS } from "@/fps/presets"; +import { floatToFrameRate, frameRateToFloat } from "@/fps/utils"; +import { useEditor } from "@/editor/use-editor"; import { Section, SectionContent, @@ -22,7 +22,7 @@ import { BackgroundContent } from "./background"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { NumberField } from "@/components/ui/number-field"; -import { useEditorStore } from "@/stores/editor-store"; +import { useEditorStore } from "@/editor/editor-store"; import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; import { HugeiconsIcon } from "@hugeicons/react"; import { Tick02Icon } from "@hugeicons/core-free-icons"; @@ -30,7 +30,7 @@ import { cn } from "@/utils/ui"; import { dimensionToAspectRatio } from "@/utils/geometry"; import { formatNumberForDisplay } from "@/utils/math"; import { OcSquarePlusIcon } from "@/components/icons"; -import type { TCanvasSize } from "@/lib/project/types"; +import type { TCanvasSize } from "@/project/types"; type SettingsView = "project-info" | "background"; diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx deleted file mode 100644 index c7a1029c..00000000 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ /dev/null @@ -1,563 +0,0 @@ -"use client";
-
-import { useEffect, useState } from "react";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
-import { useSoundSearch } from "@/hooks/use-sound-search";
-import { useSoundsStore } from "@/stores/sounds-store";
-import type { SavedSound, SoundEffect } from "@/lib/sounds/types";
-import { cn } from "@/utils/ui";
-import {
- FavouriteIcon,
- FilterMailIcon,
- PauseIcon,
- PlayIcon,
- PlusSignIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-
-export function SoundsView() {
- return (
- <div className="flex h-full flex-col">
- <Tabs defaultValue="sound-effects" className="flex h-full flex-col">
- <div className="px-3 pt-4 pb-0">
- <TabsList>
- <TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
- <TabsTrigger value="saved">Saved</TabsTrigger>
- </TabsList>
- </div>
- <Separator className="my-4" />
- <TabsContent
- value="sound-effects"
- className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
- >
- <SoundEffectsView />
- </TabsContent>
- <TabsContent
- value="saved"
- className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
- >
- <SavedSoundsView />
- </TabsContent>
- </Tabs>
- </div>
- );
-}
-
-function SoundEffectsView() {
- const {
- topSoundEffects,
- isLoading,
- searchQuery,
- setSearchQuery,
- scrollPosition,
- setScrollPosition,
- loadSavedSounds,
- showCommercialOnly,
- toggleCommercialFilter,
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- } = useSoundsStore();
- const {
- results: searchResults,
- isLoading: isSearching,
- loadMore,
- hasNextPage,
- isLoadingMore,
- } = useSoundSearch({
- query: searchQuery,
- commercialOnly: showCommercialOnly,
- });
-
- const [playingId, setPlayingId] = useState<number | null>(null);
- const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
- null,
- );
-
- const { scrollAreaRef, handleScroll } = useInfiniteScroll({
- onLoadMore: loadMore,
- hasMore: hasNextPage,
- isLoading: isLoadingMore || isSearching,
- });
-
- useEffect(() => {
- loadSavedSounds();
- }, [loadSavedSounds]);
-
- useEffect(() => {
- if (hasLoaded) {
- return;
- }
-
- let shouldIgnore = false;
-
- const fetchTopSounds = async () => {
- try {
- if (!shouldIgnore) {
- setLoading({ loading: true });
- setError({ error: null });
- }
-
- const response = await fetch(
- "/api/sounds/search?page_size=50&sort=downloads",
- );
-
- if (!shouldIgnore) {
- if (!response.ok) {
- throw new Error(`Failed to fetch: ${response.status}`);
- }
-
- const data = await response.json();
- setTopSoundEffects({ sounds: data.results });
- setHasLoaded({ loaded: true });
-
- setCurrentPage({ page: 1 });
- setHasNextPage({ hasNext: !!data.next });
- setTotalCount({ count: data.count });
- }
- } catch (error) {
- if (!shouldIgnore) {
- console.error("Failed to fetch top sounds:", error);
- setError({
- error:
- error instanceof Error ? error.message : "Failed to load sounds",
- });
- }
- } finally {
- if (!shouldIgnore) {
- setLoading({ loading: false });
- }
- }
- };
-
- const timeoutId = setTimeout(fetchTopSounds, 100, {});
-
- return () => {
- shouldIgnore = true;
- clearTimeout(timeoutId);
- };
- }, [
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- ]);
-
- useEffect(() => {
- if (!scrollAreaRef.current || scrollPosition <= 0) {
- return;
- }
-
- const restoreScrollPosition = () => {
- scrollAreaRef.current?.scrollTo({ top: scrollPosition });
- };
-
- const timeoutId = setTimeout(restoreScrollPosition, 100, {});
-
- return () => clearTimeout(timeoutId);
- }, [scrollPosition, scrollAreaRef]);
-
- const handleScrollWithPosition = ({
- currentTarget,
- }: React.UIEvent<HTMLDivElement>) => {
- const { scrollTop } = currentTarget;
- setScrollPosition({ position: scrollTop });
- handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>);
- };
-
- const displayedSounds = searchQuery ? searchResults : topSoundEffects;
-
- const playSound = ({ sound }: { sound: SoundEffect }) => {
- if (playingId === sound.id) {
- audioElement?.pause();
- setPlayingId(null);
- return;
- }
-
- audioElement?.pause();
-
- if (sound.previewUrl) {
- const audio = new Audio(sound.previewUrl);
- audio.addEventListener("ended", () => {
- setPlayingId(null);
- });
- audio.addEventListener("error", () => {
- setPlayingId(null);
- });
- audio.play().catch((error) => {
- console.error("Failed to play sound preview:", error);
- setPlayingId(null);
- });
-
- setAudioElement(audio);
- setPlayingId(sound.id);
- }
- };
-
- return (
- <div className="mt-1 flex h-full flex-col gap-5">
- <div className="flex items-center gap-3">
- <Input
- placeholder="Search sound effects"
- className="w-full"
- containerClassName="w-full"
- value={searchQuery}
- onChange={({ currentTarget }) =>
- setSearchQuery({ query: currentTarget.value })
- }
- showClearIcon
- onClear={() => setSearchQuery({ query: "" })}
- />
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="text"
- size="icon"
- className={cn(showCommercialOnly && "text-primary")}
- >
- <HugeiconsIcon icon={FilterMailIcon} />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-56">
- <DropdownMenuCheckboxItem
- checked={showCommercialOnly}
- onCheckedChange={() => toggleCommercialFilter()}
- >
- Show only commercially licensed
- </DropdownMenuCheckboxItem>
- <div className="text-muted-foreground px-2 py-1.5 text-xs">
- {showCommercialOnly
- ? "Only showing sounds licensed for commercial use"
- : "Showing all sounds regardless of license"}
- </div>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
-
- <div className="relative h-full overflow-hidden">
- <ScrollArea
- className="h-full flex-1"
- ref={scrollAreaRef}
- onScrollCapture={handleScrollWithPosition}
- >
- <div className="flex flex-col gap-4">
- {isLoading && !searchQuery && (
- <div className="text-muted-foreground text-sm">
- Loading sounds...
- </div>
- )}
- {isSearching && searchQuery && (
- <div className="text-muted-foreground text-sm">Searching...</div>
- )}
- {displayedSounds.map((sound) => (
- <AudioItem
- key={sound.id}
- sound={sound}
- isPlaying={playingId === sound.id}
- onPlay={playSound}
- />
- ))}
- {!isLoading && !isSearching && displayedSounds.length === 0 && (
- <div className="text-muted-foreground text-sm">
- {searchQuery ? "No sounds found" : "No sounds available"}
- </div>
- )}
- {isLoadingMore && (
- <div className="text-muted-foreground py-4 text-center text-sm">
- Loading more sounds...
- </div>
- )}
- </div>
- </ScrollArea>
- </div>
- </div>
- );
-}
-
-function SavedSoundsView() {
- const {
- savedSounds,
- isLoadingSavedSounds,
- savedSoundsError,
- loadSavedSounds,
- clearSavedSounds,
- } = useSoundsStore();
-
- const [playingId, setPlayingId] = useState<number | null>(null);
- const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
- null,
- );
-
- const [showClearDialog, setShowClearDialog] = useState(false);
-
- useEffect(() => {
- loadSavedSounds();
- }, [loadSavedSounds]);
-
- const playSound = ({ sound }: { sound: SoundEffect }) => {
- if (playingId === sound.id) {
- audioElement?.pause();
- setPlayingId(null);
- return;
- }
-
- audioElement?.pause();
-
- if (sound.previewUrl) {
- const audio = new Audio(sound.previewUrl);
- audio.addEventListener("ended", () => {
- setPlayingId(null);
- });
- audio.addEventListener("error", () => {
- setPlayingId(null);
- });
- audio.play().catch((error) => {
- console.error("Failed to play sound preview:", error);
- setPlayingId(null);
- });
-
- setAudioElement(audio);
- setPlayingId(sound.id);
- }
- };
-
- const convertToSoundEffect = ({
- savedSound,
- }: {
- savedSound: SavedSound;
- }): SoundEffect => ({
- id: savedSound.id,
- name: savedSound.name,
- description: "",
- url: "",
- previewUrl: savedSound.previewUrl,
- downloadUrl: savedSound.downloadUrl,
- duration: savedSound.duration,
- filesize: 0,
- type: "audio",
- channels: 0,
- bitrate: 0,
- bitdepth: 0,
- samplerate: 0,
- username: savedSound.username,
- tags: savedSound.tags,
- license: savedSound.license,
- created: savedSound.savedAt,
- downloads: 0,
- rating: 0,
- ratingCount: 0,
- });
-
- if (isLoadingSavedSounds) {
- return (
- <div className="flex h-full items-center justify-center">
- <div className="text-muted-foreground text-sm">
- Loading saved sounds...
- </div>
- </div>
- );
- }
-
- if (savedSoundsError) {
- return (
- <div className="flex h-full items-center justify-center">
- <div className="text-destructive text-sm">
- Error: {savedSoundsError}
- </div>
- </div>
- );
- }
-
- if (savedSounds.length === 0) {
- return (
- <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
- <HugeiconsIcon
- icon={FavouriteIcon}
- className="text-muted-foreground size-10"
- />
- <div className="flex flex-col gap-2 text-center">
- <p className="text-lg font-medium">No saved sounds</p>
- <p className="text-muted-foreground text-sm text-balance">
- Click the heart icon on any sound to save it here
- </p>
- </div>
- </div>
- );
- }
-
- return (
- <div className="mt-1 flex h-full flex-col gap-5">
- <div className="flex items-center justify-between">
- <p className="text-muted-foreground text-sm">
- {savedSounds.length} saved{" "}
- {savedSounds.length === 1 ? "sound" : "sounds"}
- </p>
- <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
- <DialogTrigger asChild>
- <Button
- variant="text"
- size="sm"
- className="text-muted-foreground hover:text-destructive h-auto !opacity-100"
- >
- Clear all
- </Button>
- </DialogTrigger>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Clear all saved sounds?</DialogTitle>
- <DialogDescription>
- This will permanently remove all {savedSounds.length} saved
- sounds from your collection. This action cannot be undone.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter>
- <Button variant="text" onClick={() => setShowClearDialog(false)}>
- Cancel
- </Button>
- <Button
- variant="destructive"
- onClick={async ({
- stopPropagation,
- }: React.MouseEvent<HTMLButtonElement>) => {
- stopPropagation();
- await clearSavedSounds();
- setShowClearDialog(false);
- }}
- >
- Clear all sounds
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </div>
-
- <div className="relative h-full overflow-hidden">
- <ScrollArea className="h-full flex-1">
- <div className="flex flex-col gap-4">
- {savedSounds.map((sound) => (
- <AudioItem
- key={sound.id}
- sound={convertToSoundEffect({ savedSound: sound })}
- isPlaying={playingId === sound.id}
- onPlay={playSound}
- />
- ))}
- </div>
- </ScrollArea>
- </div>
- </div>
- );
-}
-
-interface AudioItemProps {
- sound: SoundEffect;
- isPlaying: boolean;
- onPlay: ({ sound }: { sound: SoundEffect }) => void;
-}
-
-function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
- const { addSoundToTimeline, isSoundSaved, toggleSavedSound } =
- useSoundsStore();
- const isSaved = isSoundSaved({ soundId: sound.id });
-
- const handleClick = () => {
- onPlay({ sound });
- };
-
- const handleSaveClick = ({
- stopPropagation,
- }: React.MouseEvent<HTMLButtonElement>) => {
- stopPropagation();
- toggleSavedSound({ soundEffect: sound });
- };
-
- const handleAddToTimeline = async ({
- stopPropagation,
- }: React.MouseEvent<HTMLButtonElement>) => {
- stopPropagation();
- await addSoundToTimeline({ sound });
- };
-
- return (
- <div className="group flex items-center gap-3 opacity-100 hover:opacity-75">
- <button
- type="button"
- className="flex min-w-0 flex-1 items-center gap-3 text-left"
- onClick={handleClick}
- >
- <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md">
- <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" />
- {isPlaying ? (
- <HugeiconsIcon icon={PauseIcon} className="size-5" />
- ) : (
- <HugeiconsIcon icon={PlayIcon} className="size-5" />
- )}
- </div>
-
- <div className="min-w-0 flex-1 overflow-hidden">
- <p className="truncate text-sm font-medium">{sound.name}</p>
- <span className="text-muted-foreground block truncate text-xs">
- {sound.username}
- </span>
- </div>
- </button>
-
- <div className="flex items-center gap-3 pr-2">
- <Button
- variant="text"
- size="icon"
- className="text-muted-foreground hover:text-foreground w-auto !opacity-100"
- onClick={handleAddToTimeline}
- title="Add to timeline"
- >
- <HugeiconsIcon icon={PlusSignIcon} />
- </Button>
- <Button
- variant="text"
- size="icon"
- className={`hover:text-foreground w-auto !opacity-100 ${
- isSaved
- ? "text-red-500 hover:text-red-600"
- : "text-muted-foreground"
- }`}
- onClick={handleSaveClick}
- title={isSaved ? "Remove from saved" : "Save sound"}
- >
- <HugeiconsIcon
- icon={FavouriteIcon}
- className={`${isSaved ? "fill-current" : ""}`}
- />
- </Button>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx deleted file mode 100644 index 318aaccb..00000000 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ /dev/null @@ -1,455 +0,0 @@ -"use client"; - -import Image from "next/image"; -import type { CSSProperties } from "react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Spinner } from "@/components/ui/spinner"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useEditor } from "@/hooks/use-editor"; -import { resolveStickerIntrinsicSize } from "@/lib/stickers"; -import { - buildGraphicElement, - buildStickerElement, -} from "@/lib/timeline/element-utils"; -import { STICKER_CATEGORIES } from "@/lib/stickers/categories"; -import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers"; -import { parseShapeStickerId } from "@/lib/stickers/providers/shapes"; -import type { TimelineDragData } from "@/lib/timeline/drag"; -import type { - StickerBrowseSection, - StickerCategory, - StickerItem as StickerData, -} from "@/lib/stickers"; -import { useStickersStore } from "@/stores/stickers-store"; -import { cn } from "@/utils/ui"; -import { - HappyIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function StickersView() { - const { - browseContent, - browseStickers, - searchQuery, - searchStickers, - selectedCategory, - setSearchQuery, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - useEffect(() => { - if (viewMode === "browse" && !browseContent) { - void browseStickers(); - } - }, [browseContent, browseStickers, viewMode]); - - return ( - <div className="flex h-full flex-col py-2"> - <div className="px-2"> - <Input - size="sm" - variant="default" - placeholder="Search..." - value={searchQuery} - onChange={(e) => { - setSearchQuery({ query: e.target.value }); - void searchStickers({ query: e.target.value }); - }} - showClearIcon - onClear={() => { - setSearchQuery({ query: "" }); - void searchStickers({ query: "" }); - }} - className="w-full" - containerClassName="w-full" - /> - </div> - - <Tabs - value={selectedCategory} - onValueChange={(value) => { - setSelectedCategory({ category: value as StickerCategory }); - }} - variant="underline" - className="mt-2 flex min-h-0 flex-1 flex-col" - > - <TabsList aria-label="Sticker categories"> - {Object.entries(STICKER_CATEGORIES).map(([key, label]) => ( - <TabsTrigger key={key} value={key}> - {label} - </TabsTrigger> - ))} - </TabsList> - <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4"> - <StickersContentView /> - </div> - </Tabs> - </div> - ); -} - -function StickerGrid({ - items, - shouldCapSize = false, -}: { - items: StickerData[]; - shouldCapSize?: boolean; -}) { - const gridStyle: CSSProperties & { - "--sticker-min": string; - "--sticker-max"?: string; - } = { - gridTemplateColumns: shouldCapSize - ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))" - : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))", - "--sticker-min": "80px", - ...(shouldCapSize ? { "--sticker-max": "140px" } : {}), - }; - - return ( - <div className="grid gap-2" style={gridStyle}> - {items.map((item) => ( - <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} /> - ))} - </div> - ); -} - -function StickerRow({ items }: { items: StickerData[] }) { - return ( - <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden"> - {items.map((item) => ( - <div key={item.id} className="w-20 shrink-0"> - <StickerItem item={item} shouldCapSize containerClassName="w-full" /> - </div> - ))} - </div> - ); -} - -function EmptyView({ message }: { message: string }) { - return ( - <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={HappyIcon} - className="text-muted-foreground size-10" - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">No stickers found</p> - <p className="text-muted-foreground text-sm text-balance">{message}</p> - </div> - </div> - ); -} - -function RegionBanner({ region }: { region: string }) { - return ( - <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2"> - <svg - width="12" - height="12" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - className="shrink-0 text-sky-600" - aria-hidden="true" - > - <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /> - <circle cx="12" cy="10" r="3" /> - </svg> - <span className="text-xs font-semibold text-sky-600">{region}</span> - </div> - ); -} - -function StickersContentView() { - const { - browseContent, - clearRecentStickers, - isBrowsing, - isSearching, - searchQuery, - searchResults, - selectedCategory, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - if (viewMode === "search") { - if (isSearching) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ); - } - - if (searchResults?.items.length) { - const normalizedQuery = searchQuery.trim().toLowerCase(); - const isRegionSearch = - selectedCategory === "flags" && - resolveQueryToRegions({ query: normalizedQuery }) !== null; - const regionLabel = getRegionLabel({ query: normalizedQuery }); - - return ( - <div className="flex flex-col gap-3 pb-4"> - {isRegionSearch && <RegionBanner region={regionLabel} />} - <div className="flex items-center justify-between"> - <span className="text-muted-foreground text-sm"> - {searchResults.total} results - </span> - </div> - <StickerGrid items={searchResults.items} /> - </div> - ); - } - - // "all" tab search — sections are in browseContent, fall through to section rendering below - if (selectedCategory !== "all" && searchQuery) { - return <EmptyView message={`No stickers found for "${searchQuery}"`} />; - } - } - - if (isBrowsing && !browseContent) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ); - } - - if (!browseContent?.sections.length) { - const categoryLabel = STICKER_CATEGORIES[selectedCategory]; - return ( - <EmptyView - message={ - viewMode === "search" - ? `No stickers found for "${searchQuery}"` - : selectedCategory === "all" - ? "No stickers available yet." - : `No stickers available in ${categoryLabel.toLowerCase()} yet.` - } - /> - ); - } - - return ( - <div className="flex flex-col gap-4 pb-4"> - {browseContent.sections.map((section) => ( - <StickerSection - key={section.id} - section={section} - onClearRecent={clearRecentStickers} - onSeeAll={(category) => { - setSelectedCategory({ category }); - }} - /> - ))} - </div> - ); -} - -function StickerSection({ - section, - onClearRecent, - onSeeAll, -}: { - section: StickerBrowseSection; - onClearRecent: () => void; - onSeeAll: (category: StickerCategory) => void; -}) { - const hasHeader = - Boolean(section.title) || section.id === "recent" || section.action; - - return ( - <div className="flex flex-col gap-2"> - {hasHeader && ( - <div className="flex items-center justify-between gap-2"> - {section.title ? ( - <p className="text-xs text-muted-foreground">{section.title}</p> - ) : ( - <div /> - )} - - <div className="ml-auto flex items-center gap-2"> - {section.id === "recent" && ( - <Button - onClick={onClearRecent} - variant="text" - size="sm" - className="h-auto gap-1 p-0 text-xs text-muted-foreground" - > - Clear - </Button> - )} - - {section.action?.type === "see-all" && section.action.category && ( - <Button - variant="text" - size="sm" - className="h-auto gap-1 p-0 text-xs text-primary" - onClick={() => { - onSeeAll(section.action?.category as StickerCategory); - }} - > - See all - </Button> - )} - </div> - </div> - )} - - {section.layout === "row" ? ( - <StickerRow items={section.items} /> - ) : ( - <StickerGrid items={section.items} /> - )} - </div> - ); -} - -interface StickerItemProps { - item: StickerData; - shouldCapSize?: boolean; - containerClassName?: string; -} - -function StickerItem({ - item, - shouldCapSize = false, - containerClassName, -}: StickerItemProps) { - const editor = useEditor(); - const { addToRecentStickers } = useStickersStore(); - const [isAdding, setIsAdding] = useState(false); - const [hasImageError, setHasImageError] = useState(false); - - useEffect(() => { - if (!item.id) { - return; - } - - setHasImageError(false); - }, [item.id]); - - const displayName = item.name; - const shapePreset = - item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null; - - const handleAdd = async () => { - setIsAdding(true); - try { - const currentTime = editor.playback.getCurrentTime(); - - let element: - | ReturnType<typeof buildGraphicElement> - | ReturnType<typeof buildStickerElement>; - if (shapePreset) { - element = buildGraphicElement({ - definitionId: shapePreset.definitionId, - name: shapePreset.name, - startTime: currentTime, - params: shapePreset.params, - }); - } else { - const { width: intrinsicWidth, height: intrinsicHeight } = - await resolveStickerIntrinsicSize({ stickerId: item.id }); - element = buildStickerElement({ - stickerId: item.id, - name: item.name, - startTime: currentTime, - intrinsicWidth, - intrinsicHeight, - }); - } - - editor.timeline.insertElement({ - placement: { mode: "auto" }, - element, - }); - - addToRecentStickers({ stickerId: item.id }); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } finally { - setIsAdding(false); - } - }; - - const preview = ( - <div className="flex size-full items-center justify-center p-3"> - {hasImageError ? ( - <span className="text-muted-foreground text-center text-xs break-all"> - {displayName} - </span> - ) : ( - <Image - src={item.previewUrl} - alt={displayName} - width={64} - height={64} - className="size-full object-contain" - style={ - shouldCapSize - ? { - maxWidth: "var(--sticker-max, 160px)", - maxHeight: "var(--sticker-max, 160px)", - } - : undefined - } - onError={() => setHasImageError(true)} - loading="lazy" - unoptimized - /> - )} - </div> - ); - - const dragData: TimelineDragData = shapePreset - ? { - id: item.id, - type: "graphic", - name: displayName, - definitionId: shapePreset.definitionId, - params: shapePreset.params ?? {}, - } - : { - id: item.id, - type: "sticker", - name: displayName, - stickerId: item.id, - }; - - return ( - <div - className={cn("relative", isAdding && "pointer-events-none opacity-50")} - > - <DraggableItem - name={displayName} - preview={preview} - dragData={dragData} - onAddToTimeline={handleAdd} - aspectRatio={1} - shouldShowLabel={false} - isRounded - variant="card" - containerClassName={containerClassName ?? "w-full"} - /> - {isAdding && ( - <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> - <Spinner className="size-6 text-white" /> - </div> - )} - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx deleted file mode 100644 index da295da3..00000000 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import { useEditor } from "@/hooks/use-editor";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { buildTextElement } from "@/lib/timeline/element-utils";
-
-export function TextView() {
- const editor = useEditor();
-
- const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
- const activeScene = editor.scenes.getActiveScene();
- if (!activeScene) return;
-
- const element = buildTextElement({
- raw: DEFAULTS.text.element,
- startTime: currentTime,
- });
-
- editor.timeline.insertElement({
- element,
- placement: { mode: "auto" },
- });
- };
-
- return (
- <PanelView title="Text">
- <DraggableItem
- name="Default text"
- preview={
- <div className="bg-accent flex size-full items-center justify-center rounded">
- <span className="text-xs select-none">Default text</span>
- </div>
- }
- dragData={{
- id: "temp-text-id",
- type: DEFAULTS.text.element.type,
- name: DEFAULTS.text.element.name,
- content: DEFAULTS.text.element.content,
- }}
- aspectRatio={1}
- onAddToTimeline={handleAddToTimeline}
- shouldShowLabel={false}
- />
- </PanelView>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx deleted file mode 100644 index 3d96f762..00000000 --- a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client";
-
-import { useEditor } from "@/hooks/use-editor";
-import { findCurrentScene } from "@/lib/scenes";
-import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
-
-export function BookmarkNoteOverlay() {
- const editor = useEditor();
- const currentTime = editor.playback.getCurrentTime();
- const activeProject = editor.project.getActive();
-
- if (!activeProject) {
- return null;
- }
-
- const activeScene = findCurrentScene({
- scenes: activeProject.scenes,
- currentSceneId: activeProject.currentSceneId,
- });
-
- if (!activeScene) {
- return null;
- }
-
- const bookmarks = activeScene.bookmarks;
- const activeBookmarks = getBookmarksActiveAtTime({
- bookmarks,
- time: currentTime,
- });
- const bookmarksWithNotes = activeBookmarks.filter(
- (bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
- );
-
- if (bookmarksWithNotes.length === 0) {
- return null;
- }
-
- return (
- <div
- className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
- aria-live="polite"
- >
- {bookmarksWithNotes.map((bookmark) => (
- <div
- key={bookmark.time}
- className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
- style={{
- backgroundColor: "rgb(0 0 0 / 0.5)",
- borderLeft: bookmark.color
- ? `3px solid ${bookmark.color}`
- : "3px solid var(--primary)",
- }}
- >
- {bookmark.note}
- </div>
- ))}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx deleted file mode 100644 index 677f35db..00000000 --- a/apps/web/src/components/editor/panels/preview/context-menu.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client";
-
-import {
- ContextMenuCheckboxItem,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
-} from "@/components/ui/context-menu";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useEditor } from "@/hooks/use-editor";
-import { usePreviewStore } from "@/stores/preview-store";
-import { toast } from "sonner";
-
-export function PreviewContextMenu({
- onToggleFullscreen,
- containerRef,
-}: {
- onToggleFullscreen: () => void;
- containerRef: React.RefObject<HTMLElement | null>;
-}) {
- const editor = useEditor();
- const viewport = usePreviewViewport();
- const { overlays, setOverlayVisibility } = usePreviewStore();
-
- const handleCopySnapshot = async () => {
- const result = await editor.renderer.copySnapshot();
-
- if (!result.success) {
- toast.error("Failed to copy snapshot", {
- description: result.error ?? "Please try again",
- });
- return;
- }
- };
-
- const handleSaveSnapshot = async () => {
- const result = await editor.renderer.saveSnapshot();
-
- if (!result.success) {
- toast.error("Failed to save snapshot", {
- description: result.error ?? "Please try again",
- });
- return;
- }
- };
-
- return (
- <ContextMenuContent className="w-56" container={containerRef.current}>
- <ContextMenuItem onClick={viewport.fitToScreen} inset>
- Fit to screen
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem onClick={onToggleFullscreen} inset>
- Full screen
- </ContextMenuItem>
- <ContextMenuItem onClick={handleSaveSnapshot} inset>
- Save snapshot
- </ContextMenuItem>
- <ContextMenuItem onClick={handleCopySnapshot} inset>
- Copy snapshot
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuCheckboxItem
- checked={overlays.bookmarks}
- onCheckedChange={(checked) =>
- setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
- }
- >
- Show bookmark notes
- </ContextMenuCheckboxItem>
- </ContextMenuContent>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/cursors.ts b/apps/web/src/components/editor/panels/preview/cursors.ts deleted file mode 100644 index f04dbef0..00000000 --- a/apps/web/src/components/editor/panels/preview/cursors.ts +++ /dev/null @@ -1,21 +0,0 @@ -function svgCursor({ - svg, - hotspotX, - hotspotY, -}: { - svg: string; - hotspotX: number; - hotspotY: number; -}): string { - return `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${hotspotX} ${hotspotY}, crosshair`; -} - -/** Hotspot is at the nib tip, which is where anchor points land. */ -export const PEN_CURSOR = svgCursor({ - svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> - <path d="M 1 1 L 5 2 L 13 10 L 10 13 L 2 5 Z" fill="white" stroke="#111" stroke-width="1" stroke-linejoin="round"/> - <path d="M 1 1 L 5 2 L 2 5 Z" fill="#111"/> -</svg>`, - hotspotX: 1, - hotspotY: 1, -}); diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx deleted file mode 100644 index 4270fcb3..00000000 --- a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client";
-
-import { getGuideById } from "@/lib/guides";
-import { usePreviewStore } from "@/stores/preview-store";
-import { usePreviewViewport } from "./preview-viewport";
-
-export function GuideOverlay() {
- const activeGuide = usePreviewStore((state) => state.activeGuide);
- const viewport = usePreviewViewport();
- const guide = getGuideById(activeGuide);
-
- if (!guide) {
- return null;
- }
-
- return (
- <div
- className="pointer-events-none absolute"
- style={{
- left: viewport.sceneLeft,
- top: viewport.sceneTop,
- width: viewport.sceneWidth,
- height: viewport.sceneHeight,
- }}
- >
- {guide.renderOverlay({
- width: viewport.sceneWidth,
- height: viewport.sceneHeight,
- })}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx deleted file mode 100644 index bcac8075..00000000 --- a/apps/web/src/components/editor/panels/preview/guide-popover.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client";
-
-import { AnimatePresence, motion } from "motion/react";
-import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides";
-import { usePreviewStore } from "@/stores/preview-store";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import { cn } from "@/utils/ui";
-
-export function GridPopover({ children }: { children: React.ReactNode }) {
- const activeGuide = usePreviewStore((state) => state.activeGuide);
- const toggleGuide = usePreviewStore((state) => state.toggleGuide);
- const activeGuideDef = getGuideById(activeGuide);
- const options = activeGuideDef?.renderOptions?.();
-
- return (
- <Popover>
- <PopoverTrigger>{children}</PopoverTrigger>
- <PopoverContent sideOffset={8} className="w-60 px-0">
- <div className="flex flex-col gap-2 px-4">
- <Label>Guides</Label>
- <div className="grid grid-cols-3 gap-1">
- {GUIDE_REGISTRY.map((guide) => (
- <GridItem
- key={guide.id}
- label={guide.label}
- preview={guide.renderPreview()}
- isSelected={activeGuide === guide.id}
- onClick={() => toggleGuide(guide.id)}
- />
- ))}
- </div>
- </div>
- <AnimatePresence>
- {options && (
- <motion.div
- initial={{ height: 0, opacity: 0 }}
- animate={{ height: "auto", opacity: 1 }}
- exit={{ height: 0, opacity: 0 }}
- transition={{
- height: { type: "spring", duration: 0.2, bounce: 0 },
- opacity: { duration: 0.2 },
- }}
- className="overflow-hidden"
- >
- <Separator className="my-3" />
- <div className="px-4">{options}</div>
- </motion.div>
- )}
- </AnimatePresence>
- </PopoverContent>
- </Popover>
- );
-}
-
-function GridItem({
- label,
- preview,
- isSelected,
- onClick,
-}: {
- label: string;
- preview: React.ReactNode;
- isSelected?: boolean;
- onClick: () => void;
-}) {
- return (
- <button
- type="button"
- onClick={onClick}
- aria-pressed={isSelected}
- className={cn(
- "flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5",
- isSelected && "bg-primary/5! text-primary",
- )}
- >
- <div
- className={cn(
- "aspect-video bg-foreground/5 flex items-center justify-center rounded-sm",
- isSelected && "bg-primary/5!",
- )}
- >
- {preview}
- </div>
- <span
- className={cn(
- "text-xs",
- isSelected ? "text-primary" : "text-muted-foreground",
- )}
- >
- {label}
- </span>
- </button>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx deleted file mode 100644 index 33baf9fc..00000000 --- a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx +++ /dev/null @@ -1,427 +0,0 @@ -"use client"; - -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import type { IconSvgElement } from "@hugeicons/react"; - -export const HANDLE_SIZE = 10; -export const HANDLE_HIT_AREA_SIZE = 18; -export const ICON_HANDLE_RADIUS = 10; -export const EDGE_HANDLE_THIN_SIZE = 6; -export const EDGE_HANDLE_THICK_SIZE = 14; -export const LINE_HIT_AREA_SIZE = 48; - -export function getResizeCursor({ angleDeg }: { angleDeg: number }): string { - const normalized = ((angleDeg % 180) + 180) % 180; - if (normalized < 22.5 || normalized >= 157.5) return "ew-resize"; - if (normalized < 67.5) return "nwse-resize"; - if (normalized < 112.5) return "ns-resize"; - return "nesw-resize"; -} - -export function HandleButton({ - screen, - cursor, - hitAreaSize, - className, - onPointerDown, - onPointerMove, - onPointerUp, - children, -}: { - screen: { x: number; y: number }; - cursor?: string; - hitAreaSize: number; - className?: string; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; - children: React.ReactNode; -}) { - return ( - <button - type="button" - className={cn( - "absolute flex items-center justify-center outline-none", - className, - )} - style={{ - left: screen.x - hitAreaSize / 2, - top: screen.y - hitAreaSize / 2, - width: hitAreaSize, - height: hitAreaSize, - pointerEvents: "auto", - cursor, - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} - onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} - > - {children} - </button> - ); -} - -export function CornerHandle({ - cursor, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - cursor?: string; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - <HandleButton - screen={screen} - cursor={cursor} - hitAreaSize={HANDLE_HIT_AREA_SIZE} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <div - className="rounded-sm bg-white" - style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} - /> - </HandleButton> - ); -} - -export function CircleHandle({ - cursor, - screen, - size = HANDLE_SIZE, - isSelected = false, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - cursor?: string; - screen: { x: number; y: number }; - size?: number; - isSelected?: boolean; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - <HandleButton - screen={screen} - cursor={cursor} - hitAreaSize={HANDLE_HIT_AREA_SIZE} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <div - className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")} - style={{ width: size, height: size }} - /> - </HandleButton> - ); -} - -export function EdgeHandle({ - edge, - screen, - rotation, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - edge: "right" | "left" | "bottom"; - screen: { x: number; y: number }; - rotation: number; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - const isHorizontalEdge = edge === "right" || edge === "left"; - const width = isHorizontalEdge - ? EDGE_HANDLE_THIN_SIZE - : EDGE_HANDLE_THICK_SIZE; - const height = isHorizontalEdge - ? EDGE_HANDLE_THICK_SIZE - : EDGE_HANDLE_THIN_SIZE; - const cursor = getResizeCursor({ - angleDeg: isHorizontalEdge ? rotation : rotation + 90, - }); - - return ( - <HandleButton - screen={screen} - cursor={cursor} - hitAreaSize={HANDLE_HIT_AREA_SIZE} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <div - className="rounded-sm bg-white" - style={{ width, height, transform: `rotate(${rotation}deg)` }} - /> - </HandleButton> - ); -} - -export function IconHandle({ - icon, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - icon: IconSvgElement; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - <HandleButton - screen={screen} - hitAreaSize={ICON_HANDLE_RADIUS * 2} - className="rounded-full bg-white text-black shadow-sm" - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} /> - </HandleButton> - ); -} - -export function BoundingBoxOutline({ - center, - outlineWidth, - outlineHeight, - rotation, - cursor, - dashed = false, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; - cursor?: string; - dashed?: boolean; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - <svg - className="absolute overflow-visible" - aria-hidden="true" - focusable="false" - style={{ - left: center.x - outlineWidth / 2, - top: center.y - outlineHeight / 2, - width: outlineWidth, - height: outlineHeight, - transform: `rotate(${rotation}deg)`, - transformOrigin: "center center", - pointerEvents: onPointerDown ? "auto" : "none", - cursor, - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - > - <rect - x={0.5} - y={0.5} - width={Math.max(outlineWidth - 1, 0)} - height={Math.max(outlineHeight - 1, 0)} - fill="transparent" - stroke="white" - strokeDasharray={dashed ? "4 4" : undefined} - strokeOpacity={0.75} - vectorEffect="non-scaling-stroke" - style={{ pointerEvents: onPointerDown ? "all" : "none" }} - /> - </svg> - ); -} - -export function ShapeOutline({ - center, - outlineWidth, - outlineHeight, - rotation, - pathData, - cursor, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; - pathData: string; - cursor?: string; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - <svg - className="absolute overflow-visible" - aria-hidden="true" - focusable="false" - style={{ - left: center.x - outlineWidth / 2, - top: center.y - outlineHeight / 2, - width: outlineWidth, - height: outlineHeight, - transform: `rotate(${rotation}deg)`, - transformOrigin: "center center", - pointerEvents: onPointerDown ? "auto" : "none", - cursor, - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - > - <path - d={pathData} - fill="transparent" - stroke="white" - strokeOpacity={0.75} - vectorEffect="non-scaling-stroke" - style={{ pointerEvents: onPointerDown ? "all" : "none" }} - /> - </svg> - ); -} - -export function CanvasPathOutline({ - pathData, - translateX = 0, - translateY = 0, - scaleX = 1, - scaleY = 1, - cursor, - strokeWidth = 1, - strokeOpacity = 0.75, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - pathData: string; - translateX?: number; - translateY?: number; - scaleX?: number; - scaleY?: number; - cursor?: string; - strokeWidth?: number; - strokeOpacity?: number; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - <svg - className="absolute inset-0 overflow-visible" - aria-hidden="true" - focusable="false" - style={{ - pointerEvents: onPointerDown ? "auto" : "none", - cursor, - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - > - <g - transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`} - > - <path - d={pathData} - fill="transparent" - stroke="white" - strokeWidth={strokeWidth} - strokeOpacity={strokeOpacity} - vectorEffect="non-scaling-stroke" - style={{ pointerEvents: onPointerDown ? "stroke" : "none" }} - /> - </g> - </svg> - ); -} - -export function LineOverlay({ - start, - end, - cursor, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - start: { x: number; y: number }; - end: { x: number; y: number }; - cursor?: string; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - const dx = end.x - start.x; - const dy = end.y - start.y; - const length = Math.sqrt(dx * dx + dy * dy); - const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; - const cx = (start.x + end.x) / 2; - const cy = (start.y + end.y) / 2; - - const sharedStyle = { - left: cx - length / 2, - width: length, - transform: `rotate(${angleDeg}deg)`, - transformOrigin: "center center", - }; - - return ( - <> - {onPointerDown && ( - <div - className="absolute" - style={{ - ...sharedStyle, - top: cy - LINE_HIT_AREA_SIZE / 2, - height: LINE_HIT_AREA_SIZE, - pointerEvents: "auto", - cursor, - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - /> - )} - <div - className="pointer-events-none absolute" - style={{ - ...sharedStyle, - top: cy - 0.5, - height: 1, - backgroundColor: "white", - opacity: 0.75, - }} - /> - </> - ); -} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx deleted file mode 100644 index 369d854c..00000000 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ /dev/null @@ -1,294 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef } from "react"; -import useDeepCompareEffect from "use-deep-compare-effect"; -import { useEditor } from "@/hooks/use-editor"; -import { useRafLoop } from "@/hooks/use-raf-loop"; -import { useContainerSize } from "@/hooks/use-container-size"; -import { useFullscreen } from "@/hooks/use-fullscreen"; -import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import type { RootNode } from "@/services/renderer/nodes/root-node"; -import { buildScene } from "@/services/renderer/scene-builder"; -import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; -import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; -import { GuideOverlay } from "./guide-overlay"; -import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; -import { usePreviewStore } from "@/stores/preview-store"; -import { PreviewContextMenu } from "./context-menu"; -import { PreviewToolbar } from "./toolbar"; -import { - PreviewViewportProvider, - usePreviewViewportState, -} from "./preview-viewport"; - -function usePreviewSize() { - const canvasSize = useEditor( - (e) => e.project.getActive()?.settings.canvasSize, - ); - - return { - width: canvasSize?.width, - height: canvasSize?.height, - }; -} - -function normalizeWheelDelta({ - delta, - deltaMode, - pageSize, -}: { - delta: number; - deltaMode: number; - pageSize: number; -}): number { - if (deltaMode === WheelEvent.DOM_DELTA_LINE) { - return delta * 16; - } - - if (deltaMode === WheelEvent.DOM_DELTA_PAGE) { - return delta * pageSize; - } - - return delta; -} - -export function PreviewPanel() { - const containerRef = useRef<HTMLDivElement>(null); - const { toggleFullscreen } = useFullscreen({ containerRef }); - - return ( - <div - ref={containerRef} - className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border" - > - <PreviewCanvas - containerRef={containerRef} - onToggleFullscreen={toggleFullscreen} - /> - <RenderTreeController /> - </div> - ); -} - -function RenderTreeController() { - const editor = useEditor(); - const tracks = useEditor( - (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, - ); - const mediaAssets = useEditor((e) => e.media.getAssets()); - const activeProject = useEditor((e) => e.project.getActive()); - - const { width, height } = usePreviewSize(); - - useDeepCompareEffect(() => { - if (!activeProject) return; - - const duration = editor.timeline.getTotalDuration(); - const renderTree = buildScene({ - tracks, - mediaAssets, - duration, - canvasSize: { width, height }, - background: activeProject.settings.background, - isPreview: true, - }); - - editor.renderer.setRenderTree({ renderTree }); - }, [tracks, mediaAssets, activeProject?.settings.background, width, height]); - - return null; -} - -function PreviewCanvas({ - containerRef, - onToggleFullscreen, -}: { - containerRef: React.RefObject<HTMLElement | null>; - onToggleFullscreen: () => void; -}) { - const canvasRef = useRef<HTMLCanvasElement>(null); - const viewportRef = useRef<HTMLDivElement>(null); - const lastFrameRef = useRef(-1); - const lastSceneRef = useRef<RootNode | null>(null); - const renderingRef = useRef(false); - const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); - const viewportSize = useContainerSize({ containerRef: viewportRef }); - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - const renderTree = useEditor((e) => e.renderer.getRenderTree()); - const { overlays } = usePreviewStore(); - const viewport = usePreviewViewportState({ - canvasHeight: nativeHeight, - canvasWidth: nativeWidth, - viewportHeight: viewportSize.height, - viewportRef, - viewportWidth: viewportSize.width, - }); - - const renderer = useMemo(() => { - return new CanvasRenderer({ - width: nativeWidth, - height: nativeHeight, - fps: activeProject.settings.fps, - }); - }, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]); - - const render = useCallback(() => { - if (canvasRef.current && renderTree && !renderingRef.current) { - const renderTime = Math.min( - editor.playback.getCurrentTime(), - editor.timeline.getLastFrameTime(), - ); - const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator); - const frame = Math.floor(renderTime / ticksPerFrame); - - if ( - frame !== lastFrameRef.current || - renderTree !== lastSceneRef.current - ) { - renderingRef.current = true; - lastSceneRef.current = renderTree; - lastFrameRef.current = frame; - renderer - .renderToCanvas({ - node: renderTree, - time: renderTime, - targetCanvas: canvasRef.current, - }) - .then(() => { - renderingRef.current = false; - }); - } - } - }, [renderer, renderTree, editor.playback]); - - useRafLoop(render); - - useEffect(() => { - const container = viewportRef.current; - if (!container) return; - - let pendingZoomDelta = 0; - let pendingPanDeltaX = 0; - let pendingPanDeltaY = 0; - let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null; - let panRafId: ReturnType<typeof requestAnimationFrame> | null = null; - - const onWheel = (event: WheelEvent) => { - const normalizedDeltaX = normalizeWheelDelta({ - delta: event.deltaX, - deltaMode: event.deltaMode, - pageSize: container.clientWidth, - }); - const normalizedDeltaY = normalizeWheelDelta({ - delta: event.deltaY, - deltaMode: event.deltaMode, - pageSize: container.clientHeight, - }); - const isZoomGesture = event.ctrlKey || event.metaKey; - if (isZoomGesture) { - event.preventDefault(); - pendingZoomDelta += normalizedDeltaY; - - if (zoomRafId === null) { - zoomRafId = requestAnimationFrame(() => { - const cappedDelta = - Math.sign(pendingZoomDelta) * - Math.min(Math.abs(pendingZoomDelta), 30); - const zoomFactor = Math.exp(-cappedDelta / 300); - - viewport.scaleZoom({ factor: zoomFactor }); - pendingZoomDelta = 0; - zoomRafId = null; - }); - } - - return; - } - - if (!viewport.canPan) { - return; - } - - if (normalizedDeltaX === 0 && normalizedDeltaY === 0) { - return; - } - - event.preventDefault(); - pendingPanDeltaX += normalizedDeltaX; - pendingPanDeltaY += normalizedDeltaY; - - if (panRafId === null) { - panRafId = requestAnimationFrame(() => { - viewport.panByScreenDelta({ - deltaX: pendingPanDeltaX, - deltaY: pendingPanDeltaY, - }); - pendingPanDeltaX = 0; - pendingPanDeltaY = 0; - panRafId = null; - }); - } - }; - - container.addEventListener("wheel", onWheel, { - capture: true, - passive: false, - }); - - return () => { - container.removeEventListener("wheel", onWheel, { - capture: true, - }); - if (zoomRafId !== null) { - cancelAnimationFrame(zoomRafId); - } - if (panRafId !== null) { - cancelAnimationFrame(panRafId); - } - }; - }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]); - - return ( - <PreviewViewportProvider value={viewport}> - <div className="flex size-full min-h-0 min-w-0 flex-col"> - <div className="flex min-h-0 min-w-0 flex-1 p-2 pb-0"> - <ContextMenu> - <ContextMenuTrigger asChild> - <div - ref={viewportRef} - className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden" - > - <canvas - ref={canvasRef} - width={nativeWidth} - height={nativeHeight} - className="absolute block border" - style={{ - left: viewport.sceneLeft, - top: viewport.sceneTop, - width: viewport.sceneWidth, - height: viewport.sceneHeight, - background: - activeProject.settings.background.type === "blur" - ? "transparent" - : activeProject?.settings.background.color, - }} - /> - <GuideOverlay /> - <PreviewInteractionOverlay /> - {overlays.bookmarks && <BookmarkNoteOverlay />} - </div> - </ContextMenuTrigger> - <PreviewContextMenu - onToggleFullscreen={onToggleFullscreen} - containerRef={containerRef} - /> - </ContextMenu> - </div> - <PreviewToolbar onToggleFullscreen={onToggleFullscreen} /> - </div> - </PreviewViewportProvider> - ); -} diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx deleted file mode 100644 index d32b6ff9..00000000 --- a/apps/web/src/components/editor/panels/preview/mask-handles.tsx +++ /dev/null @@ -1,308 +0,0 @@ -"use client"; - -import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useMaskHandles } from "@/hooks/use-mask-handles"; -import type { SnapLine } from "@/lib/preview/preview-snap"; -import { - CornerHandle, - CircleHandle, - CanvasPathOutline, - EdgeHandle, - IconHandle, - LineOverlay, - BoundingBoxOutline, - ShapeOutline, -} from "./handle-primitives"; - -const CUSTOM_MASK_ANCHOR_SIZE = 7; -const CUSTOM_MASK_TANGENT_SIZE = 6; -import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons"; - -export function MaskHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: (lines: SnapLine[]) => void; -}) { - const viewport = usePreviewViewport(); - const { - selectedWithMask, - handlePositions, - overlays, - isCreatingCustomMask, - handleCanvasPointerDown, - handlePointerDown, - handlePointerMove, - handlePointerUp, - } = useMaskHandles({ onSnapLinesChange }); - - if (!selectedWithMask) return null; - - const toOverlay = ({ - canvasX, - canvasY, - }: { - canvasX: number; - canvasY: number; - }) => - viewport.canvasToOverlay({ - canvasX, - canvasY, - }); - - const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); - const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 }); - - const onPointerMove = (event: React.PointerEvent) => { - if (viewport.handlePanPointerMove({ event })) { - return; - } - - handlePointerMove({ event }); - }; - const onPointerUp = (event: React.PointerEvent) => { - if (viewport.handlePanPointerUp({ event })) { - return; - } - - handlePointerUp(); - }; - const handleMaskPointerDown = ({ - event, - handleId, - }: { - event: React.PointerEvent; - handleId: string; - }) => { - if (viewport.handlePanPointerDown({ event })) { - return; - } - - handlePointerDown({ event, handleId }); - }; - const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => { - if (viewport.handlePanPointerDown({ event })) { - return; - } - - handleCanvasPointerDown({ event }); - }; - - return ( - <div - className="pointer-events-none absolute inset-0 overflow-hidden" - aria-hidden - > - {isCreatingCustomMask ? ( - <div - className="absolute inset-0 pointer-events-auto" - style={{ cursor: PEN_CURSOR }} - onPointerDown={handleCanvasOverlayPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerCancel={onPointerUp} - /> - ) : null} - {overlays.map((overlay) => { - const overlayHandleId = overlay.handleId; - const pointerHandlers = overlayHandleId - ? { - onPointerDown: (event: React.PointerEvent) => - handleMaskPointerDown({ event, handleId: overlayHandleId }), - onPointerMove, - onPointerUp, - } - : {}; - - if (overlay.type === "line") { - return ( - <LineOverlay - key={overlay.id} - cursor={overlay.cursor} - start={toOverlay({ - canvasX: overlay.start.x, - canvasY: overlay.start.y, - })} - end={toOverlay({ - canvasX: overlay.end.x, - canvasY: overlay.end.y, - })} - {...pointerHandlers} - /> - ); - } - - if (overlay.type === "rect") { - return ( - <BoundingBoxOutline - key={overlay.id} - center={toOverlay({ - canvasX: overlay.center.x, - canvasY: overlay.center.y, - })} - outlineWidth={overlay.width * scaleX} - outlineHeight={overlay.height * scaleY} - rotation={overlay.rotation} - cursor={overlay.cursor} - dashed={overlay.dashed} - {...pointerHandlers} - /> - ); - } - - if (overlay.type === "shape") { - return ( - <ShapeOutline - key={overlay.id} - center={toOverlay({ - canvasX: overlay.center.x, - canvasY: overlay.center.y, - })} - outlineWidth={overlay.width * scaleX} - outlineHeight={overlay.height * scaleY} - rotation={overlay.rotation} - pathData={overlay.pathData} - cursor={overlay.cursor} - {...pointerHandlers} - /> - ); - } - - if (overlay.type === "canvas-path") { - return ( - <CanvasPathOutline - key={overlay.id} - pathData={overlay.pathData} - translateX={ - overlay.coordinateSpace === "canvas" ? canvasOrigin.x : 0 - } - translateY={ - overlay.coordinateSpace === "canvas" ? canvasOrigin.y : 0 - } - scaleX={overlay.coordinateSpace === "canvas" ? scaleX : 1} - scaleY={overlay.coordinateSpace === "canvas" ? scaleY : 1} - cursor={overlay.cursor} - strokeWidth={overlay.strokeWidth} - strokeOpacity={overlay.strokeOpacity} - {...pointerHandlers} - /> - ); - } - - return null; - })} - {handlePositions.map((handle) => { - const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); - - if (handle.kind === "icon" && handle.icon === "rotate") { - return ( - <IconHandle - key={handle.id} - icon={Rotate01Icon} - screen={screen} - onPointerDown={(event) => - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "icon" && handle.icon === "feather") { - return ( - <IconHandle - key={handle.id} - icon={FeatherIcon} - screen={screen} - onPointerDown={(event) => - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "edge" && handle.edgeAxis === "horizontal") { - return ( - <EdgeHandle - key={handle.id} - edge="right" - screen={screen} - rotation={handle.rotation ?? 0} - onPointerDown={(event) => - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "edge" && handle.edgeAxis === "vertical") { - return ( - <EdgeHandle - key={handle.id} - edge="bottom" - screen={screen} - rotation={handle.rotation ?? 0} - onPointerDown={(event) => - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "point" || handle.kind === "tangent") { - return ( - <CircleHandle - key={handle.id} - screen={screen} - size={ - handle.kind === "tangent" - ? CUSTOM_MASK_TANGENT_SIZE - : CUSTOM_MASK_ANCHOR_SIZE - } - isSelected={handle.isSelected} - onPointerDown={(event) => - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "corner") { - return ( - <CornerHandle - key={handle.id} - screen={screen} - onPointerDown={(event) => - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - return ( - <CornerHandle - key={handle.id} - cursor={handle.cursor} - screen={screen} - onPointerDown={(event) => - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx deleted file mode 100644 index 6ee4b584..00000000 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useState } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; -import type { SnapLine } from "@/lib/preview/preview-snap"; -import { TransformHandles } from "./transform-handles"; -import { MaskHandles } from "./mask-handles"; -import { SnapGuides } from "./snap-guides"; -import { TextEditOverlay } from "./text-edit-overlay"; -import { usePropertiesStore } from "../properties/stores/properties-store"; -import { useEditor } from "@/hooks/use-editor"; - -export function PreviewInteractionOverlay() { - const [snapLines, setSnapLines] = useState<SnapLine[]>([]); - const editor = useEditor(); - const viewport = usePreviewViewport(); - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType); - - const selectedRef = - selectedElements.length === 1 ? selectedElements[0] : null; - const activeTrack = selectedRef - ? editor.timeline.getTrackById({ trackId: selectedRef.trackId }) - : null; - const activeElement = - activeTrack?.elements.find( - (element) => element.id === selectedRef?.elementId, - ) ?? null; - const isMaskMode = activeElement - ? activeTabPerType[activeElement.type] === "masks" - : false; - - const { - onPointerDown, - onPointerMove, - onPointerUp, - onDoubleClick, - editingText, - commitTextEdit, - } = usePreviewInteraction({ - onSnapLinesChange: setSnapLines, - isMaskMode, - }); - - const handlePointerDown = (event: React.PointerEvent) => { - if (viewport.handlePanPointerDown({ event })) { - return; - } - - onPointerDown(event); - }; - - const handlePointerMove = (event: React.PointerEvent) => { - if (viewport.handlePanPointerMove({ event })) { - return; - } - - onPointerMove(event); - }; - - const handlePointerUp = (event: React.PointerEvent) => { - if (viewport.handlePanPointerUp({ event })) { - return; - } - - onPointerUp(event); - }; - - return ( - <div className="absolute inset-0"> - <div - className="absolute inset-0 pointer-events-auto" - role="application" - aria-label="Preview canvas" - style={{ - cursor: viewport.isPanning - ? "grabbing" - : viewport.canPan - ? "default" - : undefined, - }} - onPointerDown={handlePointerDown} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} - onDoubleClick={onDoubleClick} - onDragStart={(e) => e.preventDefault()} - /> - {editingText ? ( - <TextEditOverlay - trackId={editingText.trackId} - elementId={editingText.elementId} - element={editingText.element} - onCommit={commitTextEdit} - /> - ) : isMaskMode ? ( - <MaskHandles onSnapLinesChange={setSnapLines} /> - ) : ( - <TransformHandles onSnapLinesChange={setSnapLines} /> - )} - <SnapGuides lines={snapLines} /> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx deleted file mode 100644 index 2ac6bd92..00000000 --- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx +++ /dev/null @@ -1,576 +0,0 @@ -"use client";
-
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import {
- canvasToOverlay,
- getDisplayScale,
- positionToOverlay,
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import { PREVIEW_ZOOM } from "@/lib/preview/zoom";
-
-const MIDDLE_MOUSE_BUTTON = 1;
-const IS_AT_FIT_EPSILON = 0.001;
-const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
-
-interface PreviewViewportContextValue {
- canPan: boolean;
- isAtFit: boolean;
- isAtActualSize: boolean;
- isPanning: boolean;
- sceneHeight: number;
- sceneLeft: number;
- sceneTop: number;
- sceneWidth: number;
- zoomPercent: number;
- canvasToOverlay: ({
- canvasX,
- canvasY,
- }: {
- canvasX: number;
- canvasY: number;
- }) => { x: number; y: number };
- fitToScreen: () => void;
- getDisplayScale: () => { x: number; y: number };
- handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
- handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
- handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
- positionToOverlay: ({
- positionX,
- positionY,
- }: {
- positionX: number;
- positionY: number;
- }) => { x: number; y: number };
- panByScreenDelta: ({
- deltaX,
- deltaY,
- }: {
- deltaX: number;
- deltaY: number;
- }) => void;
- resetPan: () => void;
- scaleZoom: ({ factor }: { factor: number }) => void;
- screenPixelsToLogicalThreshold: ({
- screenPixels,
- }: {
- screenPixels: number;
- }) => { x: number; y: number };
- screenToCanvas: ({
- clientX,
- clientY,
- }: {
- clientX: number;
- clientY: number;
- }) => { x: number; y: number } | null;
- setActualSize: () => void;
- setViewportPercent: ({ percent }: { percent: number }) => void;
- zoomIn: () => void;
- zoomOut: () => void;
-}
-
-const PreviewViewportContext =
- createContext<PreviewViewportContextValue | null>(null);
-
-interface PreviewViewportProviderProps {
- children: React.ReactNode;
- value: PreviewViewportContextValue;
-}
-
-interface PreviewViewportStateOptions {
- canvasHeight: number;
- canvasWidth: number;
- viewportHeight: number;
- viewportRef: React.RefObject<HTMLDivElement | null>;
- viewportWidth: number;
-}
-
-interface PanSession {
- centerX: number;
- centerY: number;
- clientX: number;
- clientY: number;
- pointerId: number;
-}
-
-function getClampedCenterAxis({
- axisSize,
- center,
- scale,
- viewportSize,
-}: {
- axisSize: number;
- center: number;
- scale: number;
- viewportSize: number;
-}): number {
- if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
- return axisSize / 2;
- }
-
- const visibleHalfSpan = viewportSize / (2 * scale);
- if (visibleHalfSpan >= axisSize / 2) {
- return axisSize / 2;
- }
-
- return clamp({
- value: center,
- min: visibleHalfSpan,
- max: axisSize - visibleHalfSpan,
- });
-}
-
-function clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX,
- centerY,
- scale,
- viewportHeight,
- viewportWidth,
-}: {
- canvasHeight: number;
- canvasWidth: number;
- centerX: number;
- centerY: number;
- scale: number;
- viewportHeight: number;
- viewportWidth: number;
-}): { x: number; y: number } {
- return {
- x: getClampedCenterAxis({
- center: centerX,
- axisSize: canvasWidth,
- scale,
- viewportSize: viewportWidth,
- }),
- y: getClampedCenterAxis({
- center: centerY,
- axisSize: canvasHeight,
- scale,
- viewportSize: viewportHeight,
- }),
- };
-}
-
-function getFitScale({
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportWidth,
-}: {
- canvasHeight: number;
- canvasWidth: number;
- viewportHeight: number;
- viewportWidth: number;
-}): number {
- if (
- canvasHeight <= 0 ||
- canvasWidth <= 0 ||
- viewportHeight <= 0 ||
- viewportWidth <= 0
- ) {
- return 1;
- }
-
- return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
-}
-
-function getClampedZoom({ zoom }: { zoom: number }): number {
- return clamp({
- value: zoom,
- min: PREVIEW_ZOOM.min,
- max: PREVIEW_ZOOM.max,
- });
-}
-
-export function PreviewViewportProvider({
- children,
- value,
-}: PreviewViewportProviderProps) {
- return (
- <PreviewViewportContext.Provider value={value}>
- {children}
- </PreviewViewportContext.Provider>
- );
-}
-
-export function usePreviewViewportState({
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportRef,
- viewportWidth,
-}: PreviewViewportStateOptions): PreviewViewportContextValue {
- const [zoom, setZoomState] = useState(1);
- const [center, setCenter] = useState(() => ({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- }));
- const [isPanning, setIsPanning] = useState(false);
- const panSessionRef = useRef<PanSession | null>(null);
- const centerRef = useRef(center);
- centerRef.current = center;
-
- const fitScale = useMemo(
- () =>
- getFitScale({
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportWidth,
- }),
- [canvasHeight, canvasWidth, viewportHeight, viewportWidth],
- );
- const viewportScale = fitScale * zoom;
- const geometry = useMemo(
- () => ({
- canvasHeight,
- canvasWidth,
- centerX: center.x,
- centerY: center.y,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- }),
- [
- canvasHeight,
- canvasWidth,
- center.x,
- center.y,
- viewportHeight,
- viewportScale,
- viewportWidth,
- ],
- );
-
- const scaleZoom = useCallback(({ factor }: { factor: number }) => {
- setZoomState((previousZoom) =>
- getClampedZoom({
- zoom: previousZoom * factor,
- }),
- );
- }, []);
-
- const panByScreenDelta = useCallback(
- ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
- if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
- return;
- }
-
- setCenter((previousCenter) =>
- clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX: previousCenter.x + deltaX / viewportScale,
- centerY: previousCenter.y + deltaY / viewportScale,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- }),
- );
- },
- [
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportScale,
- viewportWidth,
- zoom,
- ],
- );
-
- const resetPan = useCallback(() => {
- setCenter({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- });
- }, [canvasHeight, canvasWidth]);
-
- const fitToScreen = useCallback(() => {
- setZoomState(1);
- setCenter({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- });
- }, [canvasHeight, canvasWidth]);
-
- const zoomIn = useCallback(() => {
- setZoomState((previousZoom) =>
- getClampedZoom({
- zoom: previousZoom * PREVIEW_ZOOM.step,
- }),
- );
- }, []);
-
- const zoomOut = useCallback(() => {
- setZoomState((previousZoom) =>
- getClampedZoom({
- zoom: previousZoom / PREVIEW_ZOOM.step,
- }),
- );
- }, []);
-
- const setActualSize = useCallback(() => {
- const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
- setZoomState(
- getClampedZoom({
- zoom: actualSizeZoom,
- }),
- );
- }, [fitScale]);
-
- const setViewportPercent = useCallback(
- ({ percent }: { percent: number }) => {
- const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
- setZoomState(getClampedZoom({ zoom: targetZoom }));
- },
- [fitScale],
- );
-
- const getViewportRect = useCallback(
- () => viewportRef.current?.getBoundingClientRect() ?? null,
- [viewportRef],
- );
-
- const screenToCanvasWithViewport = useCallback(
- ({ clientX, clientY }: { clientX: number; clientY: number }) => {
- const viewportRect = getViewportRect();
- if (!viewportRect) return null;
-
- return screenToCanvas({
- clientX,
- clientY,
- geometry,
- viewportRect,
- });
- },
- [getViewportRect, geometry],
- );
-
- const canvasToOverlayWithViewport = useCallback(
- ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
- canvasToOverlay({
- canvasX,
- canvasY,
- geometry,
- }),
- [geometry],
- );
-
- const positionToOverlayWithViewport = useCallback(
- ({ positionX, positionY }: { positionX: number; positionY: number }) =>
- positionToOverlay({
- geometry,
- positionX,
- positionY,
- }),
- [geometry],
- );
-
- const getDisplayScaleWithViewport = useCallback(
- () => getDisplayScale({ geometry }),
- [geometry],
- );
-
- const getLogicalThresholdForScreenPixels = useCallback(
- ({ screenPixels }: { screenPixels: number }) =>
- screenPixelsToLogicalThreshold({
- geometry,
- screenPixels,
- }),
- [geometry],
- );
-
- const handlePanPointerDown = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
- return false;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- panSessionRef.current = {
- centerX: centerRef.current.x,
- centerY: centerRef.current.y,
- clientX: event.clientX,
- clientY: event.clientY,
- pointerId: event.pointerId,
- };
- setIsPanning(true);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
- return true;
- },
- [zoom],
- );
-
- const handlePanPointerMove = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- const panSession = panSessionRef.current;
- if (!panSession) {
- return false;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- const deltaX = event.clientX - panSession.clientX;
- const deltaY = event.clientY - panSession.clientY;
- const nextCenter = clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX: panSession.centerX - deltaX / viewportScale,
- centerY: panSession.centerY - deltaY / viewportScale,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- });
-
- setCenter(nextCenter);
- return true;
- },
- [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
- );
-
- const handlePanPointerUp = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- const panSession = panSessionRef.current;
- if (!panSession) {
- return false;
- }
-
- if (
- (event.currentTarget as HTMLElement).hasPointerCapture(
- panSession.pointerId,
- )
- ) {
- (event.currentTarget as HTMLElement).releasePointerCapture(
- panSession.pointerId,
- );
- }
-
- panSessionRef.current = null;
- setIsPanning(false);
- return true;
- },
- [],
- );
-
- useEffect(() => {
- setZoomState(1);
- setCenter({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- });
- panSessionRef.current = null;
- setIsPanning(false);
- }, [canvasHeight, canvasWidth]);
-
- useEffect(() => {
- setCenter((previousCenter) =>
- clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX: previousCenter.x,
- centerY: previousCenter.y,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- }),
- );
- }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
-
- const sceneWidth = canvasWidth * viewportScale;
- const sceneHeight = canvasHeight * viewportScale;
- const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
- const sceneTop = viewportHeight / 2 - center.y * viewportScale;
- const canPan = zoom > 1;
- const zoomPercent = Math.round(viewportScale * 100);
-
- return useMemo(
- () => ({
- canPan,
- isAtActualSize: isNearlyEqual({
- leftValue: viewportScale,
- rightValue: 1,
- epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
- }),
- isAtFit: isNearlyEqual({
- leftValue: zoom,
- rightValue: 1,
- epsilon: IS_AT_FIT_EPSILON,
- }),
- isPanning,
- sceneHeight,
- sceneLeft,
- sceneTop,
- sceneWidth,
- zoomPercent,
- canvasToOverlay: canvasToOverlayWithViewport,
- fitToScreen,
- getDisplayScale: getDisplayScaleWithViewport,
- handlePanPointerDown,
- handlePanPointerMove,
- handlePanPointerUp,
- panByScreenDelta,
- positionToOverlay: positionToOverlayWithViewport,
- resetPan,
- scaleZoom,
- screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
- screenToCanvas: screenToCanvasWithViewport,
- setActualSize,
- setViewportPercent,
- zoomIn,
- zoomOut,
- }),
- [
- canPan,
- viewportScale,
- zoom,
- isPanning,
- sceneHeight,
- sceneLeft,
- sceneTop,
- sceneWidth,
- zoomPercent,
- canvasToOverlayWithViewport,
- fitToScreen,
- getDisplayScaleWithViewport,
- handlePanPointerDown,
- handlePanPointerMove,
- handlePanPointerUp,
- panByScreenDelta,
- positionToOverlayWithViewport,
- resetPan,
- scaleZoom,
- getLogicalThresholdForScreenPixels,
- screenToCanvasWithViewport,
- setActualSize,
- setViewportPercent,
- zoomIn,
- zoomOut,
- ],
- );
-}
-
-export function usePreviewViewport() {
- const context = useContext(PreviewViewportContext);
- if (!context) {
- throw new Error(
- "usePreviewViewport must be used within PreviewViewportProvider",
- );
- }
-
- return context;
-}
diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx deleted file mode 100644 index ced5e921..00000000 --- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client";
-
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-
-export function SnapGuides({ lines }: { lines: SnapLine[] }) {
- const viewport = usePreviewViewport();
-
- if (lines.length === 0) {
- return null;
- }
-
- const toOverlayX = (logicalX: number) =>
- viewport.positionToOverlay({
- positionX: logicalX,
- positionY: 0,
- }).x;
-
- const toOverlayY = (logicalY: number) =>
- viewport.positionToOverlay({
- positionX: 0,
- positionY: logicalY,
- }).y;
-
- return (
- <div className="pointer-events-none absolute inset-0" aria-hidden>
- {lines.map((line) => {
- if (line.type === "vertical") {
- return (
- <div
- key={`vertical-${line.position}`}
- className="absolute top-0 bottom-0 w-px bg-white/70"
- style={{ left: toOverlayX(line.position) }}
- />
- );
- }
- return (
- <div
- key={`horizontal-${line.position}`}
- className="absolute left-0 right-0 h-px bg-white/70"
- style={{ top: toOverlayY(line.position) }}
- />
- );
- })}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx deleted file mode 100644 index eabbde8a..00000000 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ /dev/null @@ -1,158 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useEditor } from "@/hooks/use-editor"; -import type { TextElement } from "@/lib/timeline"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - getElementLocalTime, - resolveTransformAtTime, -} from "@/lib/animation"; -import { resolveTextLayout } from "@/lib/text/primitives"; - -export function TextEditOverlay({ - trackId, - elementId, - element, - onCommit, -}: { - trackId: string; - elementId: string; - element: TextElement; - onCommit: () => void; -}) { - const editor = useEditor(); - const viewport = usePreviewViewport(); - const divRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - const div = divRef.current; - if (!div) return; - div.focus(); - const range = document.createRange(); - range.selectNodeContents(div); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }, []); - - const handleInput = useCallback(() => { - const div = divRef.current; - if (!div) return; - const text = div.innerText; - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: { content: text } }], - }); - }, [editor.timeline, trackId, elementId]); - - const handleKeyDown = useCallback( - ({ event }: { event: React.KeyboardEvent }) => { - const { key } = event; - if (key === "Escape") { - event.preventDefault(); - onCommit(); - return; - } - }, - [onCommit], - ); - - const canvasSize = editor.project.getActive().settings.canvasSize; - - if (!canvasSize) return null; - - const currentTime = editor.playback.getCurrentTime(); - const localTime = getElementLocalTime({ - timelineTime: currentTime, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const { x: posX, y: posY } = viewport.positionToOverlay({ - positionX: transform.position.x, - positionY: transform.position.y, - }); - - const { x: displayScaleX } = viewport.getDisplayScale(); - const resolvedTextLayout = resolveTextLayout({ - text: { - content: element.content, - fontSize: element.fontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textAlign: element.textAlign, - textDecoration: element.textDecoration, - letterSpacing: element.letterSpacing, - lineHeight: element.lineHeight, - }, - canvasHeight: canvasSize.height, - }); - - const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; - const canvasLetterSpacing = element.letterSpacing ?? 0; - const lineHeightPx = resolvedTextLayout.lineHeightPx; - - const bg = element.background; - const shouldShowBackground = - bg.enabled && bg.color && bg.color !== "transparent"; - const fontSizeRatio = resolvedTextLayout.fontSizeRatio; - const canvasPaddingX = shouldShowBackground - ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio - : 0; - const canvasPaddingY = shouldShowBackground - ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio - : 0; - - return ( - <div - className="absolute" - style={{ - left: posX, - top: posY, - transform: `translate(-50%, -50%) scale(${transform.scaleX * displayScaleX}, ${transform.scaleY * displayScaleX}) rotate(${transform.rotate}deg)`, - transformOrigin: "center center", - }} - > - {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} - <div - ref={divRef} - contentEditable - suppressContentEditableWarning - tabIndex={0} - role="textbox" - aria-label="Edit text" - className="cursor-text select-text outline-none whitespace-pre" - style={{ - fontSize: resolvedTextLayout.scaledFontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight === "bold" ? "bold" : "normal", - fontStyle: element.fontStyle === "italic" ? "italic" : "normal", - textAlign: element.textAlign, - letterSpacing: `${canvasLetterSpacing}px`, - lineHeight, - color: "transparent", - caretColor: element.color, - backgroundColor: shouldShowBackground ? bg.color : "transparent", - minHeight: lineHeightPx, - textDecoration: element.textDecoration ?? "none", - padding: shouldShowBackground - ? `${canvasPaddingY}px ${canvasPaddingX}px` - : 0, - minWidth: 1, - }} - onInput={handleInput} - onBlur={onCommit} - onKeyDown={(event) => handleKeyDown({ event })} - > - {element.content || ""} - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx deleted file mode 100644 index 9ee6c822..00000000 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { formatTimecode } from "opencut-wasm"; -import { invokeAction } from "@/lib/actions"; -import { EditableTimecode } from "@/components/editable-timecode"; -import { Button } from "@/components/ui/button"; -import { - FullScreenIcon, - GridTableIcon, - PauseIcon, - PlayIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { getGuideById } from "@/lib/guides"; -import { Separator } from "@/components/ui/separator"; -import { - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectSeparator, -} from "@/components/ui/select"; -import { PREVIEW_ZOOM_PRESETS } from "@/lib/preview/zoom"; -import { usePreviewViewport } from "./preview-viewport"; -import { GridPopover } from "./guide-popover"; -import { usePreviewStore } from "@/stores/preview-store"; - -export function PreviewToolbar({ - onToggleFullscreen, -}: { - onToggleFullscreen: () => void; -}) { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const activeGuideDefinition = getGuideById(activeGuide); - - return ( - <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5"> - <TimecodeDisplay /> - <PlayPauseButton /> - <div className="justify-self-end flex items-center gap-2.5"> - <ZoomSelect /> - <Separator orientation="vertical" className="h-4" /> - {/* v0.4.0 */} - {/* <GridPopover> - <Button - variant={activeGuideDefinition ? "secondary" : "text"} - size="icon" - > - {activeGuideDefinition ? ( - activeGuideDefinition.renderTriggerIcon() - ) : ( - <HugeiconsIcon icon={GridTableIcon} /> - )} - </Button> - </GridPopover> */} - <Button variant="text" onClick={onToggleFullscreen}> - <HugeiconsIcon icon={FullScreenIcon} /> - </Button> - </div> - </div> - ); -} - -function TimecodeDisplay() { - const editor = useEditor(); - const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); - const fps = useEditor((e) => e.project.getActive().settings.fps); - const [currentTime, setCurrentTime] = useState(() => - editor.playback.getCurrentTime(), - ); - - useEffect(() => { - const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); - window.addEventListener("playback-update", handler); - window.addEventListener("playback-seek", handler); - return () => { - window.removeEventListener("playback-update", handler); - window.removeEventListener("playback-seek", handler); - }; - }, []); - - return ( - <div className="flex items-center"> - <EditableTimecode - time={currentTime} - duration={totalDuration} - format="HH:MM:SS:FF" - fps={fps} - onTimeChange={({ time }) => editor.playback.seek({ time })} - className="text-center" - /> - <span className="text-muted-foreground px-2 font-mono text-xs">/</span> - <span className="text-muted-foreground font-mono text-xs"> - {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })} - </span> - </div> - ); -} - -function ZoomSelect() { - const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } = - usePreviewViewport(); - - const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`; - - const onValueChange = (value: string) => { - if (value === "fit") { - fitToScreen(); - } else { - setViewportPercent({ percent: Number(value) }); - } - }; - - return ( - <Select - value={isAtFit ? "fit" : String(zoomPercent)} - onValueChange={onValueChange} - > - <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger> - <SelectContent> - <SelectItem value="fit">Fit</SelectItem> - <SelectSeparator /> - {PREVIEW_ZOOM_PRESETS.map((preset) => ( - <SelectItem key={preset} value={String(preset)}> - {preset}% - </SelectItem> - ))} - </SelectContent> - </Select> - ); -} - -function PlayPauseButton() { - const isPlaying = useEditor((e) => e.playback.getIsPlaying()); - - return ( - <Button - variant="text" - size="icon" - onClick={() => invokeAction("toggle-play")} - > - <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> - </Button> - ); -} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx deleted file mode 100644 index 2b81b557..00000000 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useTransformHandles } from "@/hooks/use-transform-handles"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { - getCornerPosition, - getEdgeHandlePosition, - ROTATION_HANDLE_OFFSET, - type Corner, - type Edge, -} from "@/lib/preview/element-bounds"; -import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; -import { - BoundingBoxOutline, - CornerHandle, - EdgeHandle, - IconHandle, - getResizeCursor, -} from "./handle-primitives"; -import { Rotate01Icon } from "@hugeicons/core-free-icons"; - -const CORNERS: Corner[] = [ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]; -const EDGES: Edge[] = ["right", "left", "bottom"]; - -export function TransformHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: OnSnapLinesChange; -}) { - const viewport = usePreviewViewport(); - const { - selectedWithBounds, - hasVisualSelection, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, - } = useTransformHandles({ onSnapLinesChange }); - - if (!hasVisualSelection || !selectedWithBounds) return null; - - const { bounds, element } = selectedWithBounds; - if (!isVisualElement(element)) return null; - - const displayScale = viewport.getDisplayScale(); - - const toOverlay = ({ - canvasX, - canvasY, - }: { - canvasX: number; - canvasY: number; - }) => - viewport.canvasToOverlay({ - canvasX, - canvasY, - }); - - const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); - const outlineWidth = Math.abs(bounds.width) * displayScale.x; - const outlineHeight = Math.abs(bounds.height) * displayScale.y; - - const rotationAngleRad = (bounds.rotation * Math.PI) / 180; - const topCenterLocalY = -bounds.height / 2; - const topCenterScreen = toOverlay({ - canvasX: bounds.cx - topCenterLocalY * Math.sin(rotationAngleRad), - canvasY: bounds.cy + topCenterLocalY * Math.cos(rotationAngleRad), - }); - const rotationHandleScreen = { - x: topCenterScreen.x + Math.sin(rotationAngleRad) * ROTATION_HANDLE_OFFSET, - y: topCenterScreen.y - Math.cos(rotationAngleRad) * ROTATION_HANDLE_OFFSET, - }; - - const onPointerMove = (event: React.PointerEvent) => - handlePointerMove({ event }); - const onPointerUp = () => handlePointerUp(); - - return ( - <div - className="pointer-events-none absolute inset-0 overflow-hidden" - aria-hidden - > - <BoundingBoxOutline - center={center} - outlineWidth={outlineWidth} - outlineHeight={outlineHeight} - rotation={bounds.rotation} - /> - {CORNERS.map((corner) => { - const cornerPosition = getCornerPosition({ bounds, corner }); - const screen = toOverlay({ - canvasX: cornerPosition.x, - canvasY: cornerPosition.y, - }); - const angleDeg = - Math.atan2(screen.y - center.y, screen.x - center.x) * - (180 / Math.PI); - return ( - <CornerHandle - key={corner} - cursor={getResizeCursor({ angleDeg })} - screen={screen} - onPointerDown={(event) => - handleCornerPointerDown({ event, corner }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} - {EDGES.map((edge) => { - const edgePosition = getEdgeHandlePosition({ bounds, edge }); - const screen = toOverlay({ - canvasX: edgePosition.x, - canvasY: edgePosition.y, - }); - return ( - <EdgeHandle - key={edge} - edge={edge} - screen={screen} - rotation={bounds.rotation} - onPointerDown={(event) => handleEdgePointerDown({ event, edge })} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} - <IconHandle - icon={Rotate01Icon} - screen={rotationHandleScreen} - onPointerDown={(event) => handleRotationPointerDown({ event })} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - </div> - ); -} 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 && (
- <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
- <p className="text-sm">Audio has been separated.</p>
- <Button
- className="mt-3"
- size="sm"
- variant="secondary"
- onClick={() =>
- editor.timeline.toggleSourceAudioSeparation({
- trackId,
- elementId: element.id,
- })
- }
- >
- Recover audio
- </Button>
- </div>
- )}
- <Section collapsible sectionKey={`${element.id}:audio`}>
- <SectionHeader>
- <SectionTitle>Audio</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Volume"
- beforeLabel={
- <KeyframeToggle
- isActive={volume.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle volume keyframe"
- onToggle={volume.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={VolumeHighIcon} />}
- value={volume.displayValue}
- onFocus={volume.onFocus}
- onChange={volume.onChange}
- onBlur={volume.onBlur}
- dragSensitivity="slow"
- scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
- onScrub={volume.scrubTo}
- onScrubEnd={volume.commitScrub}
- onReset={() =>
- volume.commitValue({
- value: DEFAULTS.element.volume,
- })
- }
- isDefault={isDefault}
- suffix="dB"
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- </>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx 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 ( - <Section collapsible sectionKey={`${element.id}:blending`}> - <SectionHeader> - <SectionTitle>Blending</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField - label="Opacity" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={opacity.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle opacity keyframe" - onToggle={opacity.toggleKeyframe} - /> - } - > - <NumberField - className="w-full" - icon={ - <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => - opacity.commitValue({ value: DEFAULTS.element.opacity }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOpacity, - staticValue: element.opacity, - defaultValue: DEFAULTS.element.opacity, - })} - dragSensitivity="slow" - /> - </SectionField> - <SectionField label="Blend mode" className="w-1/2"> - <Select - value={committedBlendModeRef.current} - onOpenChange={handleBlendModeOpenChange} - onValueChange={commitBlendMode} - > - <SelectTrigger - icon={<HugeiconsIcon icon={RainDropIcon} />} - className="w-full" - > - <SelectValue placeholder="Select blend mode" /> - </SelectTrigger> - <SelectContent className="w-36" onPointerLeave={onPointerLeave}> - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> - {group.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - onPointerEnter={() => - previewBlendMode({ value: option.value }) - } - > - {option.label} - </SelectItem> - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - <SelectSeparator /> - ) : null} - </Fragment> - ))} - </SelectContent> - </Select> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx 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 ( - <div className="flex flex-col h-full"> - <div className="border-b px-3.5 h-11 shrink-0 flex items-center"> - <SectionTitle>Effect</SectionTitle> - </div> - <EffectSection - effect={effect} - renderParams={(renderElement as EffectElement).params} - previewParam={previewParam} - onCommit={commit} - /> - </div> - ); -} - -export function ClipEffectsTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const [dragIndex, setDragIndex] = useState<number | null>(null); - const [dropIndex, setDropIndex] = useState<number | null>(null); - const editor = useEditor(); - const { renderElement, previewUpdates, commit } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const effects: Effect[] = element.effects ?? []; - - const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => { - return ( - (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId) - ?.params ?? - effects.find((ef) => ef.id === effectId)?.params ?? - {} - ); - }; - - const buildPreviewParam = - (effectId: string) => - (key: string) => - (value: number | string | boolean) => { - const updatedEffects = ( - (renderElement as VisualElement).effects ?? [] - ).map((existing) => - existing.id !== effectId - ? existing - : { ...existing, params: { ...existing.params, [key]: value } }, - ); - previewUpdates({ effects: updatedEffects }); - }; - - const handleDragStart = ({ index }: { index: number }) => setDragIndex(index); - - const handleDragOver = ({ - event, - index, - }: { - event: React.DragEvent; - index: number; - }) => { - event.preventDefault(); - if (index !== dropIndex) setDropIndex(index); - }; - - const handleDrop = ({ toIndex }: { toIndex: number }) => { - if (dragIndex !== null && dragIndex !== toIndex) { - editor.timeline.reorderClipEffects({ - trackId, - elementId: element.id, - fromIndex: dragIndex, - toIndex, - }); - } - setDragIndex(null); - setDropIndex(null); - }; - - const handleDragEnd = () => { - setDragIndex(null); - setDropIndex(null); - }; - - return ( - <div className="flex flex-col h-full"> - <div className="border-b px-3.5 h-11 shrink-0 flex items-center"> - <SectionTitle>Effects</SectionTitle> - </div> - {effects.length === 0 ? ( - <EmptyView /> - ) : ( - <ul className="flex flex-col"> - {effects.map((effect, index) => { - const resolvedDragIndex = dragIndex ?? -1; - const isDragging = dragIndex === index; - const isDropTarget = - dropIndex === index && dragIndex !== null && dragIndex !== index; - const showTopDropIndicator = - isDropTarget && index < resolvedDragIndex; - const showBottomDropIndicator = - isDropTarget && index > resolvedDragIndex; - - return ( - <li - key={effect.id} - draggable - onDragStart={() => handleDragStart({ index })} - onDragOver={(event) => handleDragOver({ event, index })} - onDrop={() => handleDrop({ toIndex: index })} - onDragEnd={handleDragEnd} - className={cn( - "group list-none", - isDragging && "opacity-40", - showTopDropIndicator && "border-t-2 border-primary", - showBottomDropIndicator && "border-b-2 border-primary", - )} - > - <EffectSection - effect={effect} - renderParams={getRenderParams({ effectId: effect.id })} - previewParam={buildPreviewParam(effect.id)} - onCommit={commit} - onToggle={() => - editor.timeline.toggleClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - onRemove={() => - editor.timeline.removeClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - /> - </li> - ); - })} - </ul> - )} - </div> - ); -} - -function EmptyView() { - const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab); - - return ( - <div className="flex flex-col h-full items-center justify-center gap-4 text-center"> - <HugeiconsIcon - icon={MagicWand05Icon} - className="size-10 text-muted-foreground" - strokeWidth={1} - /> - <div className="flex flex-col gap-2"> - <h3 className="font-medium text-foreground">No effects</h3> - <p className="text-muted-foreground text-sm text-balance max-w-44"> - Add effects to this layer from the Assets panel. - </p> - </div> - <Button - variant="default" - size="sm" - onClick={() => setActiveTab("effects")} - > - Open effects - </Button> - </div> - ); -} - -function EffectSection({ - effect, - renderParams, - previewParam, - onCommit, - onToggle, - onRemove, -}: { - effect: Effect; - renderParams: ParamValues; - previewParam: (key: string) => (value: number | string | boolean) => void; - onCommit: () => void; - onToggle?: () => void; - onRemove?: () => void; -}) { - const definition = effectsRegistry.get(effect.type); - - return ( - <Section - sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined} - showTopBorder={false} - > - <SectionHeader - className={cn(onToggle && "cursor-move")} - trailing={ - onToggle && ( - <div className="flex items-center gap-1"> - <Button - variant={effect.enabled ? "secondary" : "ghost"} - size="icon" - aria-label={`Toggle ${definition.name}`} - onClick={onToggle} - > - <HugeiconsIcon - icon={effect.enabled ? ViewIcon : ViewOffSlashIcon} - /> - </Button> - <Button - variant="ghost" - size="icon" - aria-label={`Remove ${definition.name}`} - onClick={onRemove} - > - <HugeiconsIcon icon={Delete02Icon} /> - </Button> - </div> - ) - } - > - <SectionTitle - className={cn(onToggle && !effect.enabled && "text-muted-foreground")} - > - {definition.name} - </SectionTitle> - </SectionHeader> - <SectionContent - className={cn("p-0", onToggle && !effect.enabled && "opacity-50")} - > - <SectionFields> - {definition.params.map((param) => ( - <div key={param.key} className="flex flex-col gap-3.5"> - <div className="px-4"> - <PropertyParamField - param={param} - value={renderParams[param.key] ?? param.default} - onPreview={previewParam(param.key)} - onCommit={onCommit} - /> - </div> - <Separator /> - </div> - ))} - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx 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 (
- <div className="flex flex-col">
- <Section collapsible sectionKey={`${element.id}:graphic`}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {shapeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- {hasStrokeParams && (
- <StrokeSection element={element} trackId={trackId} />
- )}
- </div>
- );
-}
-
-function StrokeSection({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicParamsAtTime({
- element: liveElement,
- localTime,
- });
- const strokeParams = definition.params.filter((p) => p.group === "stroke");
- const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
- const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
-
- const toggleStroke = () => {
- if (isStrokeEnabled) {
- lastStrokeWidth.current = Number(
- element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
- );
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { params: { ...element.params, strokeWidth: 0 } },
- },
- ],
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- params: {
- ...element.params,
- strokeWidth: lastStrokeWidth.current,
- },
- },
- },
- ],
- });
- }
- };
-
- return (
- <Section
- collapsible
- defaultOpen={isStrokeEnabled}
- sectionKey={`${element.id}:stroke`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleStroke();
- }}
- >
- <HugeiconsIcon
- icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Stroke</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
- >
- <SectionFields>
- {strokeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function AnimatedGraphicParamField({
- param,
- trackId,
- element,
- localTime,
- isPlayheadWithinElementRange,
- resolvedParams,
-}: {
- param: ParamDefinition;
- trackId: string;
- element: GraphicElement;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedParams: ParamValues;
-}) {
- const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
- param,
- trackId,
- elementId: element.id,
- animations: element.animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedParams[param.key] ?? param.default,
- buildBaseUpdates: ({ value }) => ({
- params: {
- ...element.params,
- [param.key]: value,
- },
- }),
- });
-
- return (
- <PropertyParamField
- param={param}
- value={resolvedParams[param.key] ?? param.default}
- onPreview={animatedParam.onPreview}
- onCommit={animatedParam.onCommit}
- keyframe={{
- isActive: animatedParam.isKeyframedAtTime,
- isDisabled: !isPlayheadWithinElementRange,
- onToggle: animatedParam.toggleKeyframe,
- }}
- />
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx 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<MaskableElement>({ - 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<MaskableElement>, - }, - ], - }); - }; - - 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<MaskableElement>, - }, - ], - }); - } - markCommitted(); - setIsDropdownOpen(false); - }; - - const previewMaskParam = - ({ index, key }: { index: number; key: string }) => - (value: number | string | boolean) => { - if (!renderMasks[index]) { - return; - } - - const updatedMasks = renderMasks.map((existingMask, maskIndex) => - maskIndex !== index - ? existingMask - : { - ...existingMask, - params: { - ...existingMask.params, - [key]: value, - }, - }, - ); - - previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>); - }; - - return ( - <div className="flex flex-col h-full"> - <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2"> - <SectionTitle>Masks</SectionTitle> - <DropdownMenu - open={hasMask ? false : isDropdownOpen} - onOpenChange={handleDropdownOpenChange} - > - {hasMask ? ( - <Tooltip> - <TooltipTrigger asChild> - <span className="inline-flex"> - <Button - variant="ghost" - size="icon" - disabled - aria-label="Add mask" - > - <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" /> - </Button> - </span> - </TooltipTrigger> - <TooltipContent className="max-w-56 text-balance"> - Only one mask is supported right now. If you need more, - duplicate the clip and apply a different mask to each copy. - </TooltipContent> - </Tooltip> - ) : ( - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="icon" aria-label="Add mask"> - <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" /> - </Button> - </DropdownMenuTrigger> - )} - <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}> - {maskDefs.map((definition) => ( - <DropdownMenuItem - key={definition.type} - onPointerEnter={() => - previewMask({ maskType: definition.type }) - } - onClick={() => commitMask({ maskType: definition.type })} - > - <HugeiconsIcon {...definition.icon} /> - {definition.name} - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - </div> - - {masks.length === 0 ? ( - <EmptyView onAddMask={() => setIsDropdownOpen(true)} /> - ) : ( - masks.map((mask, index) => ( - <MaskItem - key={mask.id} - trackId={trackId} - elementId={element.id} - mask={renderMasks[index] ?? mask} - previewParam={(paramKey) => - previewMaskParam({ index, key: paramKey }) - } - onCommit={commit} - /> - )) - )} - </div> - ); -} - -function MaskItem({ - trackId, - elementId, - mask, - previewParam, - onCommit, -}: MaskItemProps) { - const editor = useEditor(); - const definition = masksRegistry.get(mask.type); - - return ( - <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}> - <SectionHeader - trailing={ - <div className="flex items-center gap-1"> - <Button - variant="ghost" - size="icon" - aria-label={`Toggle ${definition.name} mask inversion`} - onClick={() => - editor.timeline.toggleMaskInverted({ - trackId, - elementId, - maskId: mask.id, - }) - } - > - <OcMirrorIcon - className={cn(mask.params.inverted && "-scale-x-100")} - /> - </Button> - <Button - variant="ghost" - size="icon" - aria-label={`Remove ${definition.name} mask`} - onClick={() => - editor.timeline.removeMask({ - trackId, - elementId, - maskId: mask.id, - }) - } - > - <HugeiconsIcon icon={Delete02Icon} /> - </Button> - </div> - } - > - <div className="flex items-center gap-2"> - <HugeiconsIcon {...definition.icon} size={14} /> - <SectionTitle className="capitalize font-normal"> - {definition.name} - </SectionTitle> - </div> - </SectionHeader> - <SectionContent> - <MaskParamsFields - mask={mask} - definition={definition} - previewParam={previewParam} - onCommit={onCommit} - /> - </SectionContent> - </Section> - ); -} - -function MaskParamsFields({ - mask, - definition, - previewParam, - onCommit, -}: { - mask: Mask; - definition: RegisteredMaskDefinition; - previewParam: PreviewParamHandler; - onCommit: () => void; -}) { - const featherParam = getNumberParamDefinition({ - definition, - key: "feather", - }); - const strokeWidthParam = getNumberParamDefinition({ - definition, - key: "strokeWidth", - }); - const previewNumberParam = (key: string) => (value: number) => - previewParam(key)(value); - const previewStrokeColor = previewParam("strokeColor"); - const strokeAlignParam = definition.params.find( - (param): param is SelectParamDefinition<string> => - param.key === "strokeAlign" && param.type === "select", - ); - - return ( - <SectionFields> - {isTextMask(mask) ? ( - <TextMaskFields - mask={mask} - previewParam={previewParam} - onCommit={onCommit} - fontSizeParam={getNumberParamDefinition({ - definition, - key: "fontSize", - })} - /> - ) : null} - {definition.features.hasPosition && - "centerX" in mask.params && - "centerY" in mask.params && ( - <SectionField label="Position"> - <div className="flex items-center gap-2"> - <MaskNumberField - className="flex-1" - icon="X" - param={getNumberParamDefinition({ - definition, - key: "centerX", - })} - value={getMaskNumber({ - params: mask.params, - key: "centerX", - })} - onPreview={previewNumberParam("centerX")} - onCommit={onCommit} - /> - <MaskNumberField - className="flex-1" - icon="Y" - param={getNumberParamDefinition({ - definition, - key: "centerY", - })} - value={getMaskNumber({ - params: mask.params, - key: "centerY", - })} - onPreview={previewNumberParam("centerY")} - onCommit={onCommit} - /> - </div> - </SectionField> - )} - - {definition.features.sizeMode === "width-height" && - "width" in mask.params && - "height" in mask.params && ( - <SectionField label="Size"> - <div className="flex items-center gap-2"> - <MaskNumberField - className="flex-1" - icon="W" - param={getNumberParamDefinition({ - definition, - key: "width", - })} - value={getMaskNumber({ - params: mask.params, - key: "width", - })} - onPreview={previewNumberParam("width")} - onCommit={onCommit} - /> - <MaskNumberField - className="flex-1" - icon="H" - param={getNumberParamDefinition({ - definition, - key: "height", - })} - value={getMaskNumber({ - params: mask.params, - key: "height", - })} - onPreview={previewNumberParam("height")} - onCommit={onCommit} - /> - </div> - </SectionField> - )} - - {definition.features.sizeMode === "height-only" && - "height" in mask.params && ( - <SectionField label="Height"> - <MaskNumberField - icon="H" - param={getNumberParamDefinition({ - definition, - key: "height", - })} - value={getMaskNumber({ - params: mask.params, - key: "height", - })} - onPreview={previewNumberParam("height")} - onCommit={onCommit} - /> - </SectionField> - )} - - {definition.features.sizeMode === "width-only" && - "width" in mask.params && ( - <SectionField label="Width"> - <MaskNumberField - icon="W" - param={getNumberParamDefinition({ - definition, - key: "width", - })} - value={getMaskNumber({ - params: mask.params, - key: "width", - })} - onPreview={previewNumberParam("width")} - onCommit={onCommit} - /> - </SectionField> - )} - - {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( - <SectionField label="Scale"> - <MaskNumberField - icon={ - isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S" - } - param={getNumberParamDefinition({ - definition, - key: "scale", - })} - value={getMaskNumber({ - params: mask.params, - key: "scale", - })} - onPreview={previewNumberParam("scale")} - onCommit={onCommit} - /> - </SectionField> - )} - - {definition.features.hasRotation && "rotation" in mask.params && ( - <SectionField label="Rotation"> - <MaskNumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - param={getNumberParamDefinition({ - definition, - key: "rotation", - })} - value={getMaskNumber({ - params: mask.params, - key: "rotation", - })} - onPreview={previewNumberParam("rotation")} - onCommit={onCommit} - /> - </SectionField> - )} - - <SectionField label="Feather"> - <MaskNumberField - icon={<HugeiconsIcon icon={FeatherIcon} />} - param={featherParam} - value={getMaskNumber({ - params: mask.params, - key: "feather", - })} - onPreview={previewNumberParam("feather")} - onCommit={onCommit} - /> - </SectionField> - - <SectionField label="Stroke"> - <div className="flex flex-col gap-2"> - <div className="flex items-center gap-2"> - <MaskNumberField - className="flex-1" - icon="W" - param={strokeWidthParam} - value={getMaskNumber({ - params: mask.params, - key: "strokeWidth", - })} - onPreview={previewNumberParam("strokeWidth")} - onCommit={onCommit} - /> - <ColorPicker - className="" - value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()} - onChange={(color) => previewStrokeColor(`#${color}`)} - onChangeEnd={(color) => { - previewStrokeColor(`#${color}`); - onCommit(); - }} - /> - </div> - {strokeAlignParam ? ( - <Select - value={mask.params.strokeAlign} - onValueChange={(value) => { - previewParam("strokeAlign")(value); - onCommit(); - }} - > - <SelectTrigger className="w-full"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {strokeAlignParam.options.map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - ) : null} - </div> - </SectionField> - </SectionFields> - ); -} - -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 ( - <> - <SectionField label="Content"> - <Textarea - value={content.displayValue} - className="min-h-20" - onFocus={content.onFocus} - onChange={content.onChange} - onBlur={content.onBlur} - /> - </SectionField> - <SectionField label="Font"> - <FontPicker - defaultValue={mask.params.fontFamily} - onValueChange={(value) => { - previewParam("fontFamily")(value); - onCommit(); - }} - /> - </SectionField> - <SectionField label="Size"> - <MaskNumberField - icon={<HugeiconsIcon icon={TextFontIcon} />} - param={fontSizeParam} - value={mask.params.fontSize} - onPreview={previewNumberParam("fontSize")} - onCommit={onCommit} - /> - </SectionField> - <SectionField label="Spacing"> - <div className="flex items-start gap-2"> - <MaskNumberField - className="w-1/2" - icon={<OcTextWidthIcon size={14} />} - param={LETTER_SPACING_PARAM} - value={mask.params.letterSpacing ?? 0} - onPreview={previewNumberParam("letterSpacing")} - onCommit={onCommit} - /> - <MaskNumberField - className="w-1/2" - icon={<OcTextHeightIcon size={14} />} - param={LINE_HEIGHT_PARAM} - value={mask.params.lineHeight ?? 1.2} - onPreview={previewNumberParam("lineHeight")} - onCommit={onCommit} - /> - </div> - </SectionField> - </> - ); -} - -function getNumberParamDefinition({ - definition, - key, -}: { - definition: RegisteredMaskDefinition; - key: string; -}): NumberParamDefinition { - const param = definition.params.find((candidate) => candidate.key === key); - - if (!param || param.type !== "number") { - throw new Error(`Missing number param definition for mask key "${key}"`); - } - - return param; -} - -function getMaskNumber< - TParams extends Mask["params"], - TKey extends keyof TParams & string, ->({ params, key }: { params: TParams; key: TKey }): number { - const value = params[key]; - - if (typeof value !== "number") { - throw new Error(`Expected numeric mask param for "${key}"`); - } - - return value; -} - -function MaskNumberField({ - param, - value, - onPreview, - onCommit, - icon, - className, -}: { - param: NumberParamDefinition; - value: number; - onPreview: (value: number) => void; - onCommit: () => void; - icon?: React.ReactNode; - className?: string; -}) { - const isPercent = param.unit === "percent"; - const percentMax = param.max ?? 100; - const displayMultiplier = isPercent - ? 100 / percentMax - : (param.displayMultiplier ?? 1); - const min = isPercent ? 0 : param.min; - const max = isPercent ? 100 : param.max; - const step = isPercent ? 1 : param.step; - const displayValue = value * displayMultiplier; - const maxFractionDigits = getFractionDigitsForStep({ step }); - - const clampDisplay = (nextDisplayValue: number) => - max !== undefined - ? clamp({ value: nextDisplayValue, min, max }) - : Math.max(min, nextDisplayValue); - - const previewFromDisplay = (nextDisplayValue: number) => { - onPreview( - clampDisplay(snapToStep({ value: nextDisplayValue, step })) / - displayMultiplier, - ); - }; - - const draft = usePropertyDraft({ - displayValue: formatNumberForDisplay({ - value: displayValue, - maxFractionDigits, - }), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return ( - clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier - ); - }, - onPreview, - onCommit, - }); - - return ( - <NumberField - className={className} - icon={icon} - value={draft.displayValue} - dragSensitivity="slow" - onFocus={draft.onFocus} - onChange={draft.onChange} - onBlur={draft.onBlur} - onScrub={previewFromDisplay} - onScrubEnd={onCommit} - /> - ); -} - -function EmptyView({ onAddMask }: EmptyViewProps) { - return ( - <div className="flex flex-col h-full items-center justify-center gap-4 text-center"> - <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} /> - <div className="flex flex-col gap-2"> - <h3 className="font-medium text-foreground">No masks</h3> - <p className="text-muted-foreground text-sm text-balance max-w-40"> - Add a mask to hide or reveal parts of this layer. - </p> - </div> - <Button variant="default" size="sm" onClick={onAddMask}> - Add mask - </Button> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx deleted file mode 100644 index cb10aac3..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useRef } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { NumberField } from "@/components/ui/number-field";
-import { Switch } from "@/components/ui/switch";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
-import { buildConstantRetime } from "@/lib/retime";
-import {
- DEFAULT_RETIME_RATE,
- MIN_RETIME_RATE,
- MAX_RETIME_RATE,
- clampRetimeRate,
- canMaintainPitch,
-} from "@/lib/retime/rate";
-import type { AudioElement, VideoElement } from "@/lib/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import {
- formatNumberForDisplay,
- getFractionDigitsForStep,
- snapToStep,
-} from "@/utils/math";
-
-const SPEED_STEP = 0.01;
-const SPEED_FRACTION_DIGITS = getFractionDigitsForStep({ step: SPEED_STEP });
-
-function rateToDisplay({ rate }: { rate: number }): string {
- return formatNumberForDisplay({
- value: rate,
- fractionDigits: SPEED_FRACTION_DIGITS,
- });
-}
-
-function parseSpeedInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clampRetimeRate({
- rate: snapToStep({ value: parsed, step: SPEED_STEP }),
- });
-}
-
-function buildRetime({
- rate,
- maintainPitch,
-}: {
- rate: number;
- maintainPitch: boolean;
-}) {
- if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined;
- return buildConstantRetime({ rate, maintainPitch });
-}
-
-export function SpeedTab({
- element,
- trackId,
-}: {
- element: AudioElement | VideoElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const rate = clampRetimeRate({
- rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
- });
- const isPitchPreserveAvailable = canMaintainPitch({ rate });
- const maintainPitch = element.retime?.maintainPitch ?? false;
- const pendingRateRef = useRef(rate);
-
- const commitRetime = ({
- rate: nextRate,
- maintainPitch: nextMaintainPitch,
- }: {
- rate: number;
- maintainPitch: boolean;
- }) => {
- editor.timeline.updateElementRetime({
- trackId,
- elementId: element.id,
- retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
- });
- };
-
- const speedDraft = usePropertyDraft({
- displayValue: rateToDisplay({ rate }),
- parse: (input) => parseSpeedInput({ input }),
- onPreview: (nextRate) => {
- pendingRateRef.current = nextRate;
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- retime: buildRetime({ rate: nextRate, maintainPitch }),
- },
- },
- ],
- });
- },
- onCommit: () => {
- commitRetime({ rate: pendingRateRef.current, maintainPitch });
- },
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:speed`}>
- <SectionHeader>
- <SectionTitle>Speed</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField label="Speed">
- <NumberField
- icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />}
- value={speedDraft.displayValue}
- suffix="x"
- scrubRanges={[
- { from: 0.01, to: 1, pixelsPerUnit: 160 },
- { from: 1, to: 5, pixelsPerUnit: 48 },
- ]}
- scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }}
- onFocus={() => {
- pendingRateRef.current = rate;
- speedDraft.onFocus();
- }}
- onChange={speedDraft.onChange}
- onBlur={speedDraft.onBlur}
- onScrub={speedDraft.scrubTo}
- onScrubEnd={speedDraft.commitScrub}
- onReset={() =>
- commitRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
- }
- isDefault={rate === DEFAULT_RETIME_RATE}
- />
- </SectionField>
- <div className="flex items-center justify-between">
- <span className="text-sm">Change pitch</span>
- <Switch
- checked={!maintainPitch}
- disabled={!isPitchPreserveAvailable}
- onCheckedChange={(checked) =>
- commitRetime({ rate, maintainPitch: !checked })
- }
- />
- </div>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx deleted file mode 100644 index d37379ea..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx +++ /dev/null @@ -1,767 +0,0 @@ -"use client"; - -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import type { TextElement } from "@/lib/timeline"; -import { NumberField } from "@/components/ui/number-field"; -import { useRef } from "react"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { Button } from "@/components/ui/button"; -import { uppercase } from "@/utils/string"; -import { clamp, formatNumberForDisplay } from "@/utils/math"; -import { useEditor } from "@/hooks/use-editor"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/lib/text/background"; -import { - DEFAULT_TEXT_COLOR, - MAX_FONT_SIZE, - MIN_FONT_SIZE, -} from "@/lib/text/typography"; -import { usePropertyDraft } from "../hooks/use-property-draft"; -import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { isPropertyAtDefault } from "./transform-tab"; -import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - MinusSignIcon, - PlusSignIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; -import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { cn } from "@/utils/ui"; - -export function TextTab({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - return ( - <div className="flex flex-col"> - <ContentSection element={element} trackId={trackId} /> - <TypographySection element={element} trackId={trackId} /> - <SpacingSection element={element} trackId={trackId} /> - <BackgroundSection element={element} trackId={trackId} /> - </div> - ); -} - -function ContentSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const content = usePropertyDraft({ - displayValue: element.content, - parse: (input) => input, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { content: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:content`}> - <SectionHeader> - <SectionTitle>Content</SectionTitle> - </SectionHeader> - <SectionContent> - <Textarea - placeholder="Name" - value={content.displayValue} - className="min-h-20" - onFocus={content.onFocus} - onChange={content.onChange} - onBlur={content.onBlur} - /> - </SectionContent> - </Section> - ); -} - -function TypographySection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTextColor = resolveColorAtTime({ - baseColor: element.color, - animations: element.animations, - propertyPath: "color", - localTime, - }); - - const textColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedTextColor, - buildBaseUpdates: ({ value }) => ({ color: value }), - }); - - const fontSize = usePropertyDraft({ - displayValue: element.fontSize.toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: MIN_FONT_SIZE, - max: MAX_FONT_SIZE, - }); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { fontSize: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:typography`}> - <SectionHeader> - <SectionTitle>Typography</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField label="Font"> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { 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, - patch: { - fontSize: DEFAULTS.text.element.fontSize, - }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULTS.text.element.fontSize} - icon={<HugeiconsIcon icon={TextFontIcon} />} - /> - </SectionField> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={textColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle text color keyframe" - onToggle={textColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={uppercase({ - string: resolvedTextColor.replace("#", ""), - })} - onChange={(color) => textColor.onChange({ color: `#${color}` })} - onChangeEnd={textColor.onChangeEnd} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} - -function SpacingSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const letterSpacing = usePropertyDraft({ - displayValue: Math.round( - element.letterSpacing ?? DEFAULTS.text.letterSpacing, - ).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { letterSpacing: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const lineHeight = usePropertyDraft({ - displayValue: formatNumberForDisplay({ - value: element.lineHeight ?? DEFAULTS.text.lineHeight, - fractionDigits: 1, - }), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) - ? null - : Math.max(0.1, Math.round(parsed * 10) / 10); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { lineHeight: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:spacing`}> - <SectionHeader> - <SectionTitle>Spacing</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField label="Letter spacing" className="w-1/2"> - <NumberField - value={letterSpacing.displayValue} - onFocus={letterSpacing.onFocus} - onChange={letterSpacing.onChange} - onBlur={letterSpacing.onBlur} - onScrub={letterSpacing.scrubTo} - onScrubEnd={letterSpacing.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { letterSpacing: DEFAULTS.text.letterSpacing }, - }, - ], - }) - } - isDefault={ - (element.letterSpacing ?? DEFAULTS.text.letterSpacing) === - DEFAULTS.text.letterSpacing - } - icon={<OcTextWidthIcon size={14} />} - /> - </SectionField> - <SectionField label="Line height" className="w-1/2"> - <NumberField - value={lineHeight.displayValue} - onFocus={lineHeight.onFocus} - onChange={lineHeight.onChange} - onBlur={lineHeight.onBlur} - onScrub={lineHeight.scrubTo} - onScrubEnd={lineHeight.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { lineHeight: DEFAULTS.text.lineHeight }, - }, - ], - }) - } - isDefault={ - (element.lineHeight ?? DEFAULTS.text.lineHeight) === - DEFAULTS.text.lineHeight - } - icon={<OcTextHeightIcon size={14} />} - /> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} - -function BackgroundSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedBgColor = resolveColorAtTime({ - baseColor: element.background.color, - animations: element.animations, - propertyPath: "background.color", - localTime, - }); - - const bgColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedBgColor, - buildBaseUpdates: ({ value }) => ({ - background: { ...element.background, color: value }, - }), - }); - - const bg = element.background; - - const resolvedPaddingX = resolveNumberAtTime({ - baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - }); - const resolvedPaddingY = resolveNumberAtTime({ - baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - }); - const resolvedOffsetX = resolveNumberAtTime({ - baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - }); - const resolvedOffsetY = resolveNumberAtTime({ - baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - }); - const resolvedCornerRadius = resolveNumberAtTime({ - baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - }); - - const paddingX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingX: value }, - }), - }); - - const paddingY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingY: value }, - }), - }); - - const offsetX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetX: value }, - }), - }); - - const offsetY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetY: value }, - }), - }); - - const cornerRadius = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedCornerRadius).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - }); - }, - valueAtPlayhead: resolvedCornerRadius, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, cornerRadius: value }, - }), - }); - - const toggleBackgroundEnabled = () => { - const enabled = !element.background.enabled; - const color = - enabled && element.background.color === "transparent" - ? lastSelectedColor.current - : element.background.color; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - background: { - ...element.background, - enabled, - color, - }, - }, - }, - ], - }); - }; - - return ( - <Section - collapsible - defaultOpen={element.background.enabled} - sectionKey={`${element.id}:background`} - > - <SectionHeader - trailing={ - <Button - variant="ghost" - size="icon" - onClick={(event) => { - event.stopPropagation(); - toggleBackgroundEnabled(); - }} - > - <HugeiconsIcon - icon={element.background.enabled ? MinusSignIcon : PlusSignIcon} - strokeWidth={1} - /> - </Button> - } - > - <SectionTitle>Background</SectionTitle> - </SectionHeader> - <SectionContent - className={cn( - !element.background.enabled && "pointer-events-none opacity-50", - )} - > - <SectionFields> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={bgColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background color keyframe" - onToggle={bgColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={ - !element.background.enabled || - element.background.color === "transparent" - ? lastSelectedColor.current.replace("#", "") - : resolvedBgColor.replace("#", "") - } - onChange={(color) => { - const hexColor = `#${color}`; - if (color !== "transparent") { - lastSelectedColor.current = hexColor; - } - bgColor.onChange({ color: hexColor }); - }} - onChangeEnd={bgColor.onChangeEnd} - /> - </SectionField> - <div className="flex items-start gap-2"> - <SectionField - label="Width" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background width keyframe" - onToggle={paddingX.toggleKeyframe} - /> - } - > - <NumberField - icon="W" - value={paddingX.displayValue} - min={0} - onFocus={paddingX.onFocus} - onChange={paddingX.onChange} - onBlur={paddingX.onBlur} - onScrub={paddingX.scrubTo} - onScrubEnd={paddingX.commitScrub} - onReset={() => - paddingX.commitValue({ - value: DEFAULTS.text.background.paddingX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingX, - staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - defaultValue: DEFAULTS.text.background.paddingX, - })} - /> - </SectionField> - <SectionField - label="Height" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background height keyframe" - onToggle={paddingY.toggleKeyframe} - /> - } - > - <NumberField - icon="H" - value={paddingY.displayValue} - min={0} - onFocus={paddingY.onFocus} - onChange={paddingY.onChange} - onBlur={paddingY.onBlur} - onScrub={paddingY.scrubTo} - onScrubEnd={paddingY.commitScrub} - onReset={() => - paddingY.commitValue({ - value: DEFAULTS.text.background.paddingY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingY, - staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - defaultValue: DEFAULTS.text.background.paddingY, - })} - /> - </SectionField> - </div> - <div className="flex items-start gap-2"> - <SectionField - label="X-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle x-offset keyframe" - onToggle={offsetX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={offsetX.displayValue} - onFocus={offsetX.onFocus} - onChange={offsetX.onChange} - onBlur={offsetX.onBlur} - onScrub={offsetX.scrubTo} - onScrubEnd={offsetX.commitScrub} - onReset={() => - offsetX.commitValue({ - value: DEFAULTS.text.background.offsetX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetX, - staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - defaultValue: DEFAULTS.text.background.offsetX, - })} - /> - </SectionField> - <SectionField - label="Y-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle y-offset keyframe" - onToggle={offsetY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={offsetY.displayValue} - onFocus={offsetY.onFocus} - onChange={offsetY.onChange} - onBlur={offsetY.onBlur} - onScrub={offsetY.scrubTo} - onScrubEnd={offsetY.commitScrub} - onReset={() => - offsetY.commitValue({ - value: DEFAULTS.text.background.offsetY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetY, - staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - defaultValue: DEFAULTS.text.background.offsetY, - })} - /> - </SectionField> - </div> - <SectionField - label="Corner radius" - beforeLabel={ - <KeyframeToggle - isActive={cornerRadius.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle corner radius keyframe" - onToggle={cornerRadius.toggleKeyframe} - /> - } - > - <NumberField - icon="R" - value={cornerRadius.displayValue} - min={CORNER_RADIUS_MIN} - max={CORNER_RADIUS_MAX} - onFocus={cornerRadius.onFocus} - onChange={cornerRadius.onChange} - onBlur={cornerRadius.onBlur} - onScrub={cornerRadius.scrubTo} - onScrubEnd={cornerRadius.commitScrub} - onReset={() => - cornerRadius.commitValue({ value: CORNER_RADIUS_MIN }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedCornerRadius, - staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - defaultValue: CORNER_RADIUS_MIN, - })} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx deleted file mode 100644 index c3cef2d6..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx +++ /dev/null @@ -1,462 +0,0 @@ -import { NumberField } from "@/components/ui/number-field"; -import { useEditor } from "@/hooks/use-editor"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { VisualElement } from "@/lib/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowExpandIcon, - Link05Icon, - RotateClockwiseIcon, -} from "@hugeicons/core-free-icons"; -import { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, - resolveTransformAtTime, -} from "@/lib/animation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { usePropertiesStore } from "../stores/properties-store"; - -export function parseNumericInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : parsed; -} - -export function isPropertyAtDefault({ - hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue, - staticValue, - defaultValue, -}: { - hasAnimatedKeyframes: boolean; - isPlayheadWithinElementRange: boolean; - resolvedValue: number; - staticValue: number; - defaultValue: number; -}): boolean { - if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { - return isNearlyEqual({ - leftValue: resolvedValue, - rightValue: defaultValue, - }); - } - - return staticValue === defaultValue; -} - -export function TransformTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const editor = useEditor(); - const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); - const setTransformScaleLocked = usePropertiesStore( - (s) => s.setTransformScaleLocked, - ); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const positionX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.positionX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.x).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.x, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { ...element.transform.position, x: value }, - }, - }), - }); - - const positionY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.positionY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.y).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.y, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { ...element.transform.position, y: value }, - }, - }), - }); - - const parseScale = (input: string) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return parsed / 100; - }; - - const scaleX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleX, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleX: value, - ...(isScaleLocked ? { scaleY: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleY", value }] - : undefined, - }); - - const scaleY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleY, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleY: value, - ...(isScaleLocked ? { scaleX: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleX", value }] - : undefined, - }); - - const scaleFieldPropsX = { - value: scaleX.displayValue, - onFocus: scaleX.onFocus, - onChange: scaleX.onChange, - onBlur: scaleX.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleX.scrubTo, - onScrubEnd: scaleX.commitScrub, - onReset: () => - scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleX, - staticValue: element.transform.scaleX, - defaultValue: DEFAULTS.element.transform.scaleX, - }), - }; - - const scaleFieldPropsY = { - value: scaleY.displayValue, - onFocus: scaleY.onFocus, - onChange: scaleY.onChange, - onBlur: scaleY.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleY.scrubTo, - onScrubEnd: scaleY.commitScrub, - onReset: () => - scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleY, - staticValue: element.transform.scaleY, - defaultValue: DEFAULTS.element.transform.scaleY, - }), - }; - - const rotation = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.rotate", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.rotate).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return clamp({ value: parsed, min: -360, max: 360 }); - }, - valueAtPlayhead: resolvedTransform.rotate, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - rotate: value, - }, - }), - }); - - const hasScaleKeyframe = hasGroupKeyframeAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - - const toggleScaleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - const existing = getGroupKeyframesAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - if (existing.length > 0) { - editor.timeline.removeKeyframes({ - keyframes: existing.map((ref) => ({ - trackId, - elementId: element.id, - ...ref, - })), - }); - return; - } - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleX", - time: localTime, - value: resolvedTransform.scaleX, - }, - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleY", - time: localTime, - value: resolvedTransform.scaleY, - }, - ], - }); - }; - - const scaleLockButton = ( - <Button - type="button" - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setTransformScaleLocked(!isScaleLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - ); - - return ( - <Section collapsible sectionKey={`${element.id}:transform`}> - <SectionHeader> - <SectionTitle>Transform</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <div className="flex items-end gap-2"> - {isScaleLocked ? ( - <> - <SectionField - label="Scale" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={hasScaleKeyframe} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle scale keyframe" - onToggle={toggleScaleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldPropsX} - /> - </SectionField> - {scaleLockButton} - </> - ) : ( - <> - <SectionField - label="Width" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle width scale keyframe" - onToggle={scaleX.toggleKeyframe} - /> - } - > - <NumberField icon="W" {...scaleFieldPropsX} /> - </SectionField> - {scaleLockButton} - <SectionField - label="Height" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle height scale keyframe" - onToggle={scaleY.toggleKeyframe} - /> - } - > - <NumberField icon="H" {...scaleFieldPropsY} /> - </SectionField> - </> - )} - </div> - <div className="flex items-end gap-2"> - <SectionField - label="X" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={positionX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle X position keyframe" - onToggle={positionX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={positionX.displayValue} - onFocus={positionX.onFocus} - onChange={positionX.onChange} - onBlur={positionX.onBlur} - onScrub={positionX.scrubTo} - onScrubEnd={positionX.commitScrub} - onReset={() => - positionX.commitValue({ - value: DEFAULTS.element.transform.position.x, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.x, - staticValue: element.transform.position.x, - defaultValue: DEFAULTS.element.transform.position.x, - })} - /> - </SectionField> - <SectionField - label="Y" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={positionY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle Y position keyframe" - onToggle={positionY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={positionY.displayValue} - onFocus={positionY.onFocus} - onChange={positionY.onChange} - onBlur={positionY.onBlur} - onScrub={positionY.scrubTo} - onScrubEnd={positionY.commitScrub} - onReset={() => - positionY.commitValue({ - value: DEFAULTS.element.transform.position.y, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.y, - staticValue: element.transform.position.y, - defaultValue: DEFAULTS.element.transform.position.y, - })} - /> - </SectionField> - </div> - - <SectionField - label="Rotation" - beforeLabel={ - <KeyframeToggle - isActive={rotation.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle rotation keyframe" - onToggle={rotation.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - className="flex-none" - value={rotation.displayValue} - onFocus={rotation.onFocus} - onChange={rotation.onChange} - onBlur={rotation.onBlur} - dragSensitivity="slow" - onScrub={rotation.scrubTo} - onScrubEnd={rotation.commitScrub} - onReset={() => - rotation.commitValue({ - value: DEFAULTS.element.transform.rotate, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.rotate, - staticValue: element.transform.rotate, - defaultValue: DEFAULTS.element.transform.rotate, - })} - /> - </div> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx b/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx deleted file mode 100644 index 208f3beb..00000000 --- a/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx +++ /dev/null @@ -1,275 +0,0 @@ -"use client"; - -import { useCallback, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - getDbFromLinePos, - getLinePosFromDb, -} from "@/lib/timeline/audio-display"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { hasAnimatedVolume } from "@/lib/timeline/audio-state"; -import type { AudioElement } from "@/lib/timeline/types"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - isNearlyEqual, - snapToStep, -} from "@/utils/math"; -import { cn } from "@/utils/ui"; - -const HIT_AREA_HEIGHT_PX = 14; -const TOOLTIP_OFFSET_PX = 10; -const VOLUME_STEP = 0.1; -const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); - -function clampVolume({ value }: { value: number }): number { - return clamp({ - value: snapToStep({ value, step: VOLUME_STEP }), - min: VOLUME_DB_MIN, - max: VOLUME_DB_MAX, - }); -} - -function getVolumeFromPointer({ - clientY, - rect, -}: { - clientY: number; - rect: DOMRect; -}): number { - const clampedOffset = clamp({ - value: clientY - rect.top, - min: 0, - max: rect.height, - }); - const progressPercent = - rect.height <= 0 ? 0 : (clampedOffset / rect.height) * 100; - return clampVolume({ value: getDbFromLinePos({ percent: progressPercent }) }); -} - -export function AudioVolumeLine({ - element, - trackId, -}: { - element: AudioElement; - trackId: string; -}) { - const editor = useEditor(); - const surfaceRef = useRef<HTMLDivElement>(null); - const activePointerIdRef = useRef<number | null>(null); - const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume); - const lastPreviewVolumeRef = useRef( - element.volume ?? DEFAULTS.element.volume, - ); - const hasChangedRef = useRef(false); - const [isDragging, setIsDragging] = useState(false); - const [tooltipClientPos, setTooltipClientPos] = useState<{ - x: number; - y: number; - } | null>(null); - - const hasAnimatedEnvelope = hasAnimatedVolume({ element }); - const currentVolume = element.volume ?? DEFAULTS.element.volume; - const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`; - - const volumeLabel = `${formatNumberForDisplay({ - value: currentVolume, - fractionDigits: VOLUME_FRACTION_DIGITS, - })} dB`; - - const previewVolume = useCallback( - (nextVolume: number) => { - if ( - isNearlyEqual({ - leftValue: nextVolume, - rightValue: lastPreviewVolumeRef.current, - }) - ) { - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { volume: nextVolume }, - }, - ], - }); - lastPreviewVolumeRef.current = nextVolume; - hasChangedRef.current = !isNearlyEqual({ - leftValue: startVolumeRef.current, - rightValue: nextVolume, - }); - }, - [editor, element.id, trackId], - ); - - const finishDrag = useCallback( - ({ shouldCommit }: { shouldCommit: boolean }) => { - activePointerIdRef.current = null; - setIsDragging(false); - - if (shouldCommit && hasChangedRef.current) { - editor.timeline.commitPreview(); - } else { - editor.timeline.discardPreview(); - } - - hasChangedRef.current = false; - lastPreviewVolumeRef.current = startVolumeRef.current; - setTooltipClientPos(null); - }, - [editor], - ); - - const updateFromPointer = useCallback( - ({ clientX, clientY }: { clientX: number; clientY: number }) => { - const rect = surfaceRef.current?.getBoundingClientRect(); - if (!rect) { - return; - } - - setTooltipClientPos({ - x: clientX + TOOLTIP_OFFSET_PX, - y: clientY - TOOLTIP_OFFSET_PX, - }); - previewVolume(getVolumeFromPointer({ clientY, rect })); - }, - [previewVolume], - ); - - const handleClick = useCallback((event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - }, []); - - const handleMouseDown = useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - }, []); - - const handlePointerDown = useCallback( - (event: React.PointerEvent<HTMLDivElement>) => { - if (event.button !== 0) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - editor.selection.setSelectedElements({ - elements: [{ trackId, elementId: element.id }], - }); - activePointerIdRef.current = event.pointerId; - startVolumeRef.current = currentVolume; - lastPreviewVolumeRef.current = currentVolume; - hasChangedRef.current = false; - setIsDragging(true); - event.currentTarget.setPointerCapture(event.pointerId); - updateFromPointer({ - clientX: event.clientX, - clientY: event.clientY, - }); - }, - [currentVolume, editor.selection, element.id, trackId, updateFromPointer], - ); - - const handlePointerMove = useCallback( - (event: React.PointerEvent) => { - if (activePointerIdRef.current !== event.pointerId) { - return; - } - - event.preventDefault(); - updateFromPointer({ - clientX: event.clientX, - clientY: event.clientY, - }); - }, - [updateFromPointer], - ); - - const handlePointerUp = useCallback( - (event: React.PointerEvent) => { - if (activePointerIdRef.current !== event.pointerId) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - finishDrag({ shouldCommit: true }); - }, - [finishDrag], - ); - - const handlePointerCancel = useCallback( - (event: React.PointerEvent) => { - if (activePointerIdRef.current !== event.pointerId) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - finishDrag({ shouldCommit: false }); - }, - [finishDrag], - ); - - const handleLostPointerCapture = useCallback(() => { - if (activePointerIdRef.current === null) { - return; - } - - finishDrag({ shouldCommit: hasChangedRef.current }); - }, [finishDrag]); - - if (hasAnimatedEnvelope) { - return null; - } - - return ( - <div className="pointer-events-none absolute inset-0"> - <div ref={surfaceRef} className="absolute inset-0"> - <div - className={cn( - "pointer-events-none absolute inset-x-0 -translate-y-1/2 border-t transition-colors", - isDragging - ? "border-white" - : "border-white/50 group-hover/audio:border-white/80", - )} - style={{ top: lineTop }} - /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */} - <div - className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto" - style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }} - onClick={handleClick} - onMouseDown={handleMouseDown} - onPointerDown={handlePointerDown} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerCancel} - onLostPointerCapture={handleLostPointerCapture} - title="Drag to adjust clip volume" - /> - {isDragging && - tooltipClientPos && - createPortal( - <div - className="pointer-events-none fixed left-0 top-0 z-50 -translate-y-full rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white whitespace-nowrap" - style={{ - transform: `translate(${tooltipClientPos.x}px, ${tooltipClientPos.y}px)`, - }} - > - {volumeLabel} - </div>, - document.body, - )} - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx deleted file mode 100644 index a94267dd..00000000 --- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx +++ /dev/null @@ -1,345 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; -import { useResizeObserver } from "@/hooks/use-resize-observer"; -import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme"; -import { - buildWaveformSampleBuckets, - sampleSourceWaveformSummary, - type SourceWaveformSummary, -} from "@/lib/media/waveform-summary"; -import type { RetimeConfig } from "@/lib/timeline"; -import { getBarFractionFromOutputAmplitude } from "@/lib/timeline/audio-display"; -import { waveformCache } from "@/services/waveform-cache/service"; -import { findScrollParent } from "@/utils/browser"; -import { cn } from "@/utils/ui"; - -const BAR_WIDTH = 1; -const BAR_GAP = 1; -const BAR_STEP = BAR_WIDTH + BAR_GAP; -const WAVEFORM_BURN_COLOR = "rgba(255, 110, 20, 0.9)"; -export const WAVEFORM_GAIN_SAMPLE_COUNT = 200; - -function sampleGainAtClipTime({ - samples, - clipTimeSec, - clipDurationSec, -}: { - samples: number[]; - clipTimeSec: number; - clipDurationSec: number; -}): number { - if (samples.length === 0 || clipDurationSec <= 0) { - return 1; - } - - const progress = Math.max(0, Math.min(1, clipTimeSec / clipDurationSec)); - const rawIndex = progress * (samples.length - 1); - const lo = Math.floor(rawIndex); - const hi = Math.min(samples.length - 1, lo + 1); - return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo); -} - -interface AudioWaveformProps { - sourceKey: string; - sourceFile?: File; - audioUrl?: string; - audioBuffer?: AudioBuffer; - gainSamples?: number[]; - pixelsPerSecond: number; - clipDurationSec: number; - retime?: RetimeConfig; - sourceStartSec: number; - color?: string; - burnColor?: string; - className?: string; -} - -export function AudioWaveform({ - sourceKey, - sourceFile, - audioUrl, - audioBuffer, - gainSamples, - pixelsPerSecond, - clipDurationSec, - retime, - sourceStartSec, - color = TIMELINE_AUDIO_WAVEFORM_COLOR, - burnColor = WAVEFORM_BURN_COLOR, - className = "", -}: AudioWaveformProps) { - const canvasRef = useRef<HTMLCanvasElement>(null); - const containerRef = useRef<HTMLDivElement>(null); - const summaryRef = useRef<SourceWaveformSummary | null>(null); - const gainSamplesRef = useRef<number[] | undefined>(gainSamples); - const pixelsPerSecondRef = useRef<number>(pixelsPerSecond); - const clipDurationSecRef = useRef<number>(clipDurationSec); - const retimeRef = useRef<RetimeConfig | undefined>(retime); - const sourceStartSecRef = useRef<number>(sourceStartSec); - const scrollParentRef = useRef<HTMLElement | null>(null); - const heightRef = useRef<number>(0); - const lastRenderSignatureRef = useRef<string | null>(null); - - gainSamplesRef.current = gainSamples; - pixelsPerSecondRef.current = pixelsPerSecond; - clipDurationSecRef.current = clipDurationSec; - retimeRef.current = retime; - sourceStartSecRef.current = sourceStartSec; - - const clearCanvas = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) { - return; - } - - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, canvas.width, canvas.height); - } - lastRenderSignatureRef.current = null; - }, []); - - const drawVisible = useCallback(() => { - const container = containerRef.current; - const canvas = canvasRef.current; - const summary = summaryRef.current; - const height = heightRef.current; - - if (!container || !canvas || !summary || height <= 0) { - clearCanvas(); - return; - } - - const containerRect = container.getBoundingClientRect(); - const elementWidth = containerRect.width; - if (elementWidth <= 0) { - clearCanvas(); - return; - } - - const scrollParent = scrollParentRef.current; - - let clipLeft: number; - let clipRight: number; - - if (scrollParent) { - const parentRect = scrollParent.getBoundingClientRect(); - clipLeft = Math.max(0, parentRect.left - containerRect.left); - clipRight = Math.min(elementWidth, parentRect.right - containerRect.left); - } else { - clipLeft = Math.max(0, -containerRect.left); - clipRight = Math.min( - elementWidth, - window.innerWidth - containerRect.left, - ); - } - - const visibleWidth = clipRight - clipLeft; - if (visibleWidth <= 0) { - clearCanvas(); - return; - } - - const dpr = window.devicePixelRatio || 1; - const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr)); - const canvasH = Math.max(1, Math.round(height * dpr)); - const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); - const pixelsPerSecondValue = pixelsPerSecondRef.current; - const clipDurationSecValue = clipDurationSecRef.current; - const samples = gainSamplesRef.current; - const renderSignature = JSON.stringify({ - elementWidth, - clipLeft, - clipRight, - visibleWidth, - canvasW, - canvasH, - barCount, - dpr, - clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, - pixelsPerSecond: pixelsPerSecondValue, - retime: retimeRef.current ?? null, - summarySourceKey: summary.sourceKey, - summarySampleRate: summary.sampleRate, - summaryTotalSamples: summary.totalSamples, - summaryBucketSize: summary.bucketSize, - gainSamples: samples ?? null, - color, - burnColor, - }); - if (lastRenderSignatureRef.current === renderSignature) { - return; - } - lastRenderSignatureRef.current = renderSignature; - - canvas.width = canvasW; - canvas.height = canvasH; - canvas.style.width = `${visibleWidth}px`; - canvas.style.height = `${height}px`; - canvas.style.left = `${clipLeft}px`; - - const backingScaleX = dpr; - const backingScaleY = canvasH / height; - - const sampleBuckets = buildWaveformSampleBuckets({ - clipLeftPx: clipLeft, - clipRightPx: clipRight, - barCount, - pixelsPerSecond: pixelsPerSecondValue, - clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, - retime: retimeRef.current, - sampleRate: summary.sampleRate, - maxSampleExclusive: summary.totalSamples, - barStepPx: BAR_STEP, - }); - const amplitudes = sampleSourceWaveformSummary({ - summary, - buckets: sampleBuckets, - }); - - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, canvasW, canvasH); - - const clipBottom = canvasH; - - for (let i = 0; i < barCount; i++) { - const barCenterPx = clipLeft + i * BAR_STEP + BAR_WIDTH * 0.5; - const clipCenterSec = Math.max( - 0, - Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue), - ); - const gain = - samples != null - ? sampleGainAtClipTime({ - samples, - clipTimeSec: clipCenterSec, - clipDurationSec: clipDurationSecValue, - }) - : 1; - const amplitude = Math.max(0, amplitudes[i] ?? 0); - const outputAmplitude = amplitude * Math.max(0, gain); - const fraction = getBarFractionFromOutputAmplitude({ outputAmplitude }); - const barH = fraction > 0 ? Math.max(1, fraction * height) : 0; - if (barH <= 0) { - continue; - } - - const barLeft = i * BAR_STEP; - const barRight = barLeft + BAR_WIDTH; - const deviceLeft = Math.round(barLeft * backingScaleX); - const deviceRight = Math.max( - deviceLeft + 1, - Math.round(barRight * backingScaleX), - ); - const deviceTop = Math.round((height - barH) * backingScaleY); - const deviceHeight = Math.max(1, clipBottom - deviceTop); - - ctx.fillStyle = color; - ctx.fillRect( - deviceLeft, - deviceTop, - deviceRight - deviceLeft, - deviceHeight, - ); - - if (outputAmplitude > 1) { - const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY)); - ctx.fillStyle = burnColor; - ctx.fillRect( - deviceLeft, - deviceTop, - deviceRight - deviceLeft, - burnHeight, - ); - } - } - }, [burnColor, clearCanvas, color]); - - useEffect(() => { - let isCancelled = false; - summaryRef.current = null; - clearCanvas(); - - void waveformCache - .getSourceSummary({ - sourceKey, - audioBuffer, - sourceFile, - audioUrl, - }) - .then((summary) => { - if (isCancelled) { - return; - } - summaryRef.current = summary; - drawVisible(); - }) - .catch(() => { - // Waveform loading failed (e.g. corrupt file, unsupported format). - // Fail silently — a missing waveform is preferable to an error state. - if (!isCancelled) { - clearCanvas(); - } - }); - - return () => { - isCancelled = true; - }; - }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change. - useLayoutEffect(() => { - drawVisible(); - }, [ - drawVisible, - gainSamples, - pixelsPerSecond, - clipDurationSec, - retime, - sourceStartSec, - ]); - - useEffect(() => { - const container = containerRef.current; - if (!container) { - return; - } - - scrollParentRef.current = findScrollParent({ element: container }); - const scrollParent = scrollParentRef.current; - if (!scrollParent) { - return; - } - - const handleScroll = () => { - drawVisible(); - }; - scrollParent.addEventListener("scroll", handleScroll, { passive: true }); - return () => scrollParent.removeEventListener("scroll", handleScroll); - }, [drawVisible]); - - const onResize = useCallback( - (entry: ResizeObserverEntry) => { - heightRef.current = entry.contentRect.height; - drawVisible(); - }, - [drawVisible], - ); - - useResizeObserver({ ref: containerRef, onResize }); - - return ( - <div ref={containerRef} className={cn("relative size-full", className)}> - <canvas ref={canvasRef} className="absolute bottom-0" /> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx deleted file mode 100644 index c432b7d4..00000000 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ /dev/null @@ -1,427 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import type { EditorCore } from "@/core"; -import { useEditor } from "@/hooks/use-editor"; -import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; -import { - DEFAULT_TIMELINE_BOOKMARK_COLOR, -} from "./theme"; -import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout"; -import { DEFAULT_FPS } from "@/lib/fps/defaults"; -import { snappedSeekTime } from "opencut-wasm"; -import { - ArrowTurnBackwardIcon, - Delete02Icon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import type { Bookmark } from "@/lib/timeline"; -import { - Popover, - PopoverAnchor, - PopoverContent, -} from "@/components/ui/popover"; -import { Input } from "@/components/ui/input"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { uppercase } from "@/utils/string"; -import { clamp, formatNumberForDisplay } from "@/utils/math"; -import { - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/lib/timeline"; - -const MIN_BOOKMARK_WIDTH_PX = 2; -const BOOKMARK_MARKER_WIDTH_PX = 12; -const BOOKMARK_MARKER_HEIGHT_PX = 15; -const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2; -const BOOKMARK_MARKER_CLIP_PATH = - "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)"; - -function seekToBookmarkTime({ - editor, - time, -}: { - editor: EditorCore; - time: number; -}) { - const activeProject = editor.project.getActive(); - const duration = editor.timeline.getTotalDuration(); - const rate = activeProject?.settings.fps ?? DEFAULT_FPS; - const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; - editor.playback.seek({ time: snappedTime }); -} - -interface TimelineBookmarksRowProps { - zoomLevel: number; - dynamicTimelineWidth: number; - dragState: BookmarkDragState; - onBookmarkMouseDown: (params: { - event: React.MouseEvent; - bookmark: Bookmark; - }) => void; - handleWheel: (event: React.WheelEvent) => void; - handleTimelineContentClick: (event: React.MouseEvent) => void; - handleRulerTrackingMouseDown: (event: React.MouseEvent) => void; - handleRulerMouseDown: (event: React.MouseEvent) => void; -} - -export function TimelineBookmarksRow({ - zoomLevel, - dynamicTimelineWidth, - dragState, - onBookmarkMouseDown, - handleWheel, - handleTimelineContentClick, - handleRulerTrackingMouseDown, - handleRulerMouseDown, -}: TimelineBookmarksRowProps) { - const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks); - - return ( - <div - className="relative flex-1 overflow-hidden" - style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }} - > - <button - className="relative w-full cursor-default select-none border-0 bg-transparent p-0" - style={{ - height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX, - width: `${dynamicTimelineWidth}px`, - }} - aria-label="Timeline ruler" - type="button" - onWheel={handleWheel} - onClick={(event) => { - if (!event.currentTarget.contains(event.target as Node)) return; - handleTimelineContentClick(event); - }} - onMouseDown={(event) => { - if (!event.currentTarget.contains(event.target as Node)) return; - handleRulerMouseDown(event); - handleRulerTrackingMouseDown(event); - }} - > - {bookmarks.map((bookmark) => ( - <TimelineBookmark - key={`bookmark-${bookmark.time}`} - bookmark={bookmark} - zoomLevel={zoomLevel} - dragState={dragState} - onBookmarkMouseDown={onBookmarkMouseDown} - /> - ))} - </button> - </div> - ); -} - -function TimelineBookmark({ - bookmark, - zoomLevel, - dragState, - onBookmarkMouseDown, -}: { - bookmark: Bookmark; - zoomLevel: number; - dragState: BookmarkDragState; - onBookmarkMouseDown: (params: { - event: React.MouseEvent; - bookmark: Bookmark; - }) => void; -}) { - const editor = useEditor(); - const duration = editor.timeline.getTotalDuration(); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const isDragging = - dragState.isDragging && - dragState.bookmarkTime !== null && - dragState.bookmarkTime === bookmark.time; - - const displayTime = isDragging ? dragState.currentTime : bookmark.time; - const time = bookmark.time; - const bookmarkDuration = bookmark.duration ?? 0; - const durationWidth = - bookmarkDuration > 0 - ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) - : 0; - const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX; - const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0); - const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel }); - const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; - const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); - const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR; - - const handleSeek = () => seekToBookmarkTime({ editor, time }); - - const handleClick = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (event.detail === 2) { - setIsPopoverOpen(true); - } else { - handleSeek(); - } - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - handleSeek(); - }; - - const handleMouseDown = (event: React.MouseEvent) => { - onBookmarkMouseDown({ event, bookmark }); - event.preventDefault(); - event.stopPropagation(); - }; - - return ( - <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> - <PopoverAnchor asChild> - <button - className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0" - style={{ - left: `${bookmarkLeft}px`, - width: `${bookmarkWidth}px`, - }} - aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} - type="button" - onMouseDown={handleMouseDown} - onClick={handleClick} - onKeyDown={handleKeyDown} - > - {hasDurationRange ? ( - <div - className="absolute opacity-30" - style={{ - top: 1.5, - height: BOOKMARK_MARKER_HEIGHT_PX - 2.5, - left: BOOKMARK_HALF_WIDTH_PX, - width: durationWidth, - backgroundColor: iconColor, - }} - /> - ) : null} - <div - className="absolute top-0 overflow-hidden" - style={{ - left: 0, - width: BOOKMARK_HALF_WIDTH_PX, - height: BOOKMARK_MARKER_HEIGHT_PX, - }} - > - <div - className="absolute inset-0" - style={{ - width: BOOKMARK_MARKER_WIDTH_PX, - height: BOOKMARK_MARKER_HEIGHT_PX, - clipPath: BOOKMARK_MARKER_CLIP_PATH, - backgroundColor: "hsl(var(--background))", - }} - /> - <div - className="absolute" - style={{ - top: 1, - left: 1, - width: BOOKMARK_MARKER_WIDTH_PX - 2, - height: BOOKMARK_MARKER_HEIGHT_PX - 2, - clipPath: BOOKMARK_MARKER_CLIP_PATH, - backgroundColor: iconColor, - }} - /> - </div> - <div - className="absolute top-0 overflow-hidden" - style={{ - left: rightHalfLeft, - width: BOOKMARK_HALF_WIDTH_PX, - height: BOOKMARK_MARKER_HEIGHT_PX, - }} - > - <div - className="absolute top-0" - style={{ - left: -BOOKMARK_HALF_WIDTH_PX, - width: BOOKMARK_MARKER_WIDTH_PX, - height: BOOKMARK_MARKER_HEIGHT_PX, - clipPath: BOOKMARK_MARKER_CLIP_PATH, - backgroundColor: "hsl(var(--background))", - }} - /> - <div - className="absolute" - style={{ - top: 1, - left: 1 - BOOKMARK_HALF_WIDTH_PX, - width: BOOKMARK_MARKER_WIDTH_PX - 2, - height: BOOKMARK_MARKER_HEIGHT_PX - 2, - clipPath: BOOKMARK_MARKER_CLIP_PATH, - backgroundColor: iconColor, - }} - /> - </div> - </button> - </PopoverAnchor> - <PopoverContent - className="w-64 flex flex-col gap-3 p-3" - align="start" - side="bottom" - sideOffset={8} - onOpenAutoFocus={(event) => event.preventDefault()} - > - <BookmarkPopoverContent - bookmark={bookmark} - time={time} - timelineDuration={duration} - onPopoverClose={() => setIsPopoverOpen(false)} - /> - </PopoverContent> - </Popover> - ); -} - -function BookmarkPopoverContent({ - bookmark, - time, - timelineDuration, - onPopoverClose, -}: { - bookmark: Bookmark; - time: number; - timelineDuration: number; - onPopoverClose: () => void; -}) { - const editor = useEditor(); - const [draftColorHex, setDraftColorHex] = useState( - (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) - .replace("#", "") - .toUpperCase(), - ); - - useEffect(() => { - setDraftColorHex( - (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) - .replace("#", "") - .toUpperCase(), - ); - }, [bookmark.color]); - - const handleRemove = () => { - editor.scenes.removeBookmark({ time }); - onPopoverClose(); - }; - - const handleUpdate = ({ - note, - color, - duration, - }: Partial<{ note: string; color: string; duration: number }>) => { - const updates: Partial<{ note: string; color: string; duration: number }> = - {}; - if (note !== undefined && note !== bookmark.note) updates.note = note; - if ( - color !== undefined && - color.toUpperCase() !== (bookmark.color ?? "").toUpperCase() - ) { - updates.color = color; - } - if (duration !== undefined && duration !== bookmark.duration) { - updates.duration = duration; - } - if (Object.keys(updates).length === 0) return; - editor.scenes.updateBookmark({ time, updates }); - }; - - return ( - <> - <div className="flex flex-col gap-2"> - <Label className="text-xs">Note</Label> - <Input - placeholder="Add a note..." - value={bookmark.note ?? ""} - onChange={(event) => handleUpdate({ note: event.target.value })} - className="h-8 text-sm" - /> - </div> - <div className="flex flex-col gap-2"> - <Label className="text-xs">Color</Label> - <div className="relative"> - <ColorPicker - value={uppercase({ string: draftColorHex })} - onChange={(color) => setDraftColorHex(uppercase({ string: color }))} - onChangeEnd={(color) => - handleUpdate({ color: `#${uppercase({ string: color })}` }) - } - className="bg-background border" - /> - {bookmark.color && - bookmark.color.replace(/^#/, "").toUpperCase() !== - DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && ( - <Button - type="button" - variant="text" - size="text" - aria-label="Reset to default color" - className="absolute top-1/2 right-1 -translate-y-1/2 mr-1" - onClick={() => - editor.scenes.updateBookmark({ - time, - updates: { color: undefined }, - }) - } - > - <HugeiconsIcon - icon={ArrowTurnBackwardIcon} - className="!size-3.5" - /> - </Button> - )} - </div> - </div> - <div className="flex flex-col gap-2"> - <Label className="text-xs">Duration</Label> - <div className="flex items-center gap-1.5"> - <Input - type="number" - min={0} - step={0.1} - value={bookmark.duration ?? 0} - onChange={(event) => { - const parsed = parseFloat(event.target.value); - const value = Number.isNaN(parsed) - ? 0 - : clamp({ - value: parsed, - min: 0, - max: Math.max(0, timelineDuration - time), - }); - handleUpdate({ duration: value }); - }} - className="h-8 text-sm" - containerClassName="w-full" - /> - </div> - </div> - <Button - type="button" - variant="outline" - size="sm" - className="text-destructive hover:bg-destructive/10" - onClick={handleRemove} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - handleRemove(); - } - }} - aria-label="delete bookmark" - > - <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" /> - Delete - </Button> - </> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx deleted file mode 100644 index 89a4c93f..00000000 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { getDropLineY } from "./drop-target";
-import type { TimelineTrack, DropTarget } from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "./layers";
-
-interface DragLineProps {
- dropTarget: DropTarget | null;
- tracks: TimelineTrack[];
- isVisible: boolean;
- headerHeight?: number;
-}
-
-export function DragLine({
- dropTarget,
- tracks,
- isVisible,
- headerHeight = 0,
-}: DragLineProps) {
- if (!isVisible || !dropTarget) return null;
-
- const y = getDropLineY({ dropTarget, tracks });
- const lineTop = y + headerHeight;
-
- return (
- <div
- className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
- style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
- />
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/drop-target.ts b/apps/web/src/components/editor/panels/timeline/drop-target.ts deleted file mode 100644 index ef6cbd90..00000000 --- a/apps/web/src/components/editor/panels/timeline/drop-target.ts +++ /dev/null @@ -1,262 +0,0 @@ -import type { TimelineTrack, TimelineElement } from "@/lib/timeline"; -import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline"; -import { resolveTrackPlacement } from "@/lib/timeline/placement"; -import { TIMELINE_TRACK_GAP_PX } from "./layout"; -import { getTrackHeight } from "./track-layout"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; - -function findElementAtPosition({ - mouseX, - tracks, - trackIndex, - targetElementTypes, - pixelsPerSecond, - zoomLevel, -}: { - mouseX: number; - tracks: TimelineTrack[]; - trackIndex: number; - targetElementTypes: string[]; - pixelsPerSecond: number; - zoomLevel: number; -}): { elementId: string; trackId: string } | null { - const time = Math.round( - (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); - const track = tracks[trackIndex]; - if (!track || !("elements" in track)) return null; - - const hit = track.elements.find( - (element: TimelineElement) => - targetElementTypes.includes(element.type) && - element.startTime <= time && - time < element.startTime + element.duration, - ); - if (!hit) return null; - return { elementId: hit.id, trackId: track.id }; -} - -function getTrackAtY({ - mouseY, - tracks, - verticalDragDirection, -}: { - mouseY: number; - tracks: TimelineTrack[]; - verticalDragDirection?: "up" | "down" | null; -}): { trackIndex: number; relativeY: number } | null { - let cumulativeHeight = 0; - - for (let i = 0; i < tracks.length; i++) { - const trackHeight = getTrackHeight({ type: tracks[i].type }); - const trackTop = cumulativeHeight; - const trackBottom = trackTop + trackHeight; - - if (mouseY >= trackTop && mouseY < trackBottom) { - return { - trackIndex: i, - relativeY: mouseY - trackTop, - }; - } - - if (i < tracks.length - 1 && verticalDragDirection) { - const gapTop = trackBottom; - const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX; - if (mouseY >= gapTop && mouseY < gapBottom) { - const isDraggingUp = verticalDragDirection === "up"; - return { - trackIndex: isDraggingUp ? i : i + 1, - relativeY: isDraggingUp ? trackHeight - 1 : 0, - }; - } - } - - cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX; - } - - return null; -} - -const EMPTY_TARGET_ELEMENT = null; - -function fallbackNewTrackDropTarget({ - xPosition, -}: { - xPosition: number; -}): DropTarget { - return { - trackIndex: 0, - isNewTrack: true, - insertPosition: null, - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; -} - -export function computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks, - playheadTime, - isExternalDrop, - elementDuration, - pixelsPerSecond, - zoomLevel, - verticalDragDirection, - startTimeOverride, - excludeElementId, - targetElementTypes, -}: ComputeDropTargetParams): DropTarget { - const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - const mainTrackIndex = tracks.overlay.length; - const xPosition = - typeof startTimeOverride === "number" - ? startTimeOverride - : isExternalDrop - ? playheadTime - : Math.round( - Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); - - if (orderedTracks.length === 0) { - const placementResult = resolveTrackPlacement({ - tracks, - elementType, - timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], - strategy: { - type: "preferIndex", - trackIndex: 0, - hoverDirection: "below", - createNewTrackOnly: true, - }, - }); - const emptyTimelineResult = - placementResult?.kind === "newTrack" ? placementResult : null; - if (!emptyTimelineResult) { - return fallbackNewTrackDropTarget({ xPosition }); - } - - return { - trackIndex: emptyTimelineResult.insertIndex, - isNewTrack: true, - insertPosition: emptyTimelineResult.insertPosition, - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - const trackAtMouse = getTrackAtY({ - mouseY, - tracks: orderedTracks, - verticalDragDirection, - }); - - if (!trackAtMouse) { - const isAboveAllTracks = mouseY < 0; - - const placementResult = resolveTrackPlacement({ - tracks, - elementType, - timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], - strategy: { - type: "preferIndex", - trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1, - hoverDirection: isAboveAllTracks ? "above" : "below", - createNewTrackOnly: true, - }, - }); - const outOfBoundsResult = - placementResult?.kind === "newTrack" ? placementResult : null; - if (!outOfBoundsResult) { - return fallbackNewTrackDropTarget({ xPosition }); - } - - return { - trackIndex: outOfBoundsResult.insertIndex, - isNewTrack: true, - insertPosition: outOfBoundsResult.insertPosition, - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - const { trackIndex, relativeY } = trackAtMouse; - const track = orderedTracks[trackIndex]; - - if (targetElementTypes && targetElementTypes.length > 0) { - const targetElement = findElementAtPosition({ - mouseX, - tracks: orderedTracks, - trackIndex, - targetElementTypes, - pixelsPerSecond, - zoomLevel, - }); - if (targetElement) { - return { - trackIndex, - isNewTrack: false, - insertPosition: null, - xPosition, - targetElement, - }; - } - } - - const trackHeight = getTrackHeight({ type: track.type }); - const placementResult = resolveTrackPlacement({ - tracks, - elementType, - timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], - strategy: { - type: "preferIndex", - trackIndex, - hoverDirection: relativeY < trackHeight / 2 ? "above" : "below", - verticalDragDirection, - }, - }); - if (!placementResult) { - return fallbackNewTrackDropTarget({ xPosition }); - } - - if (placementResult.kind === "existingTrack") { - const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition; - - return { - trackIndex: placementResult.trackIndex, - isNewTrack: false, - insertPosition: null, - xPosition: adjustedXPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - return { - trackIndex: placementResult.insertIndex, - isNewTrack: true, - insertPosition: placementResult.insertPosition, - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; -} - -export function getDropLineY({ - dropTarget, - tracks, -}: { - dropTarget: DropTarget; - tracks: TimelineTrack[]; -}): number { - const safeTrackIndex = Math.min( - Math.max(dropTarget.trackIndex, 0), - tracks.length, - ); - let y = 0; - - for (let i = 0; i < safeTrackIndex; i++) { - y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX; - } - - return y; -} diff --git a/apps/web/src/components/editor/panels/timeline/expanded-layout.ts b/apps/web/src/components/editor/panels/timeline/expanded-layout.ts deleted file mode 100644 index 6b9734a7..00000000 --- a/apps/web/src/components/editor/panels/timeline/expanded-layout.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { - AnimationPath, - ElementAnimations, -} from "@/lib/animation/types"; -import type { TimelineTrack } from "@/lib/timeline"; -import { getElementKeyframes } from "@/lib/animation"; -import { KEYFRAME_LANE_HEIGHT_PX } from "./layout"; - -export interface ExpandedRow { - propertyPath: AnimationPath; - label: string; -} - -interface PropertyGroupDefinition { - matchesPath: (path: AnimationPath) => boolean; -} - -const PROPERTY_GROUPS: PropertyGroupDefinition[] = [ - { matchesPath: (path) => path.startsWith("transform.") || path === "opacity" }, - { matchesPath: (path) => path === "volume" || path === "color" }, - { matchesPath: (path) => path.startsWith("background.") }, - { matchesPath: (path) => path.startsWith("params.") }, - { matchesPath: (path) => path.startsWith("effects.") }, -]; - -const PROPERTY_LABELS: Partial<Record<string, string>> = { - "transform.positionX": "Position X", - "transform.positionY": "Position Y", - "transform.scaleX": "Scale X", - "transform.scaleY": "Scale Y", - "transform.rotate": "Rotation", - opacity: "Opacity", - volume: "Volume", - color: "Color", - "background.color": "BG Color", - "background.paddingX": "BG Pad X", - "background.paddingY": "BG Pad Y", - "background.offsetX": "BG Offset X", - "background.offsetY": "BG Offset Y", - "background.cornerRadius": "Corner Radius", -}; - -export function getPropertyLabel(path: AnimationPath): string { - if (PROPERTY_LABELS[path]) return PROPERTY_LABELS[path]; - if (path.startsWith("params.")) return path.slice("params.".length); - if (path.startsWith("effects.")) { - const parts = path.split("."); - return parts[parts.length - 1]; - } - return path; -} - -export function getExpandedRows({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ExpandedRow[] { - const keyframes = getElementKeyframes({ animations }); - const propertyPaths = [...new Set(keyframes.map((kf) => kf.propertyPath))]; - if (propertyPaths.length === 0) return []; - - const rows: ExpandedRow[] = []; - - for (const group of PROPERTY_GROUPS) { - const groupPaths = propertyPaths.filter((path) => - group.matchesPath(path), - ); - for (const path of groupPaths) { - rows.push({ propertyPath: path, label: getPropertyLabel(path) }); - } - } - - return rows; -} - -export function getExpansionHeight({ rows }: { rows: ExpandedRow[] }): number { - return rows.length * KEYFRAME_LANE_HEIGHT_PX; -} - -export function computeTrackExpansionHeight({ - track, - expandedElementIds, -}: { - track: TimelineTrack; - expandedElementIds: Set<string>; -}): number { - let maxHeight = 0; - for (const element of track.elements) { - if (!expandedElementIds.has(element.id)) continue; - const rows = getExpandedRows({ animations: element.animations }); - maxHeight = Math.max(maxHeight, getExpansionHeight({ rows })); - } - return maxHeight; -} - -export function getTrackExpandedRows({ - track, - expandedElementIds, -}: { - track: TimelineTrack; - expandedElementIds: Set<string>; -}): ExpandedRow[] { - let maxHeight = 0; - let maxRows: ExpandedRow[] = []; - - for (const element of track.elements) { - if (!expandedElementIds.has(element.id)) continue; - const rows = getExpandedRows({ animations: element.animations }); - const height = getExpansionHeight({ rows }); - if (height > maxHeight) { - maxHeight = height; - maxRows = rows; - } - } - - return maxRows; -} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx deleted file mode 100644 index 3f78350b..00000000 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx +++ /dev/null @@ -1,235 +0,0 @@ -"use client"; - -import { useRef, useState, type PointerEvent } from "react"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { getBezierPoint } from "@/lib/animation/bezier"; -import type { NormalizedCubicBezier } from "@/lib/animation/types"; -import { cn } from "@/utils/ui"; - -const GRAPH_WIDTH = 140; -const GRAPH_HEIGHT = 94; -const GRAPH_PADDING = 12; -const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2; -const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2; -const HANDLE_RADIUS = 3.5; -const ENDPOINT_RADIUS = 2; -const SNAP_THRESHOLD = 0.06; -const SNAP_TARGETS = [0, 1]; -const CURVE_SEGMENTS = 64; -const Y_CLAMP_MIN = -0.5; -const Y_CLAMP_MAX = 1.5; - -type BezierHandle = "c1" | "c2"; - -export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT; - -function snap({ - value, - targets, - isEnabled, -}: { - value: number; - targets: number[]; - isEnabled: boolean; -}) { - if (!isEnabled) return value; - for (const target of targets) { - if (Math.abs(value - target) < SNAP_THRESHOLD) return target; - } - return value; -} - -function toSvgX({ value }: { value: number }) { - return GRAPH_PADDING + value * GRAPH_WIDTH; -} - -function toSvgY({ value }: { value: number }) { - return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT; -} - -function fromSvgX({ svgX }: { svgX: number }) { - return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH)); -} - -function fromSvgY({ svgY }: { svgY: number }) { - return Math.max( - Y_CLAMP_MIN, - Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT), - ); -} - -function curvePath({ curve }: { curve: NormalizedCubicBezier }) { - const points: string[] = []; - for (let i = 0; i <= CURVE_SEGMENTS; i++) { - const progress = i / CURVE_SEGMENTS; - const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) }); - const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) }); - points.push(`${x},${y}`); - } - return `M${points.join("L")}`; -} - -function clampHandleY({ svgY }: { svgY: number }) { - return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY)); -} - -export function BezierGraph({ - value, - onChange, - onChangeEnd, - onCancel, -}: { - value: NormalizedCubicBezier; - onChange?: (value: NormalizedCubicBezier) => void; - onChangeEnd?: (value: NormalizedCubicBezier) => void; - onCancel?: () => void; -}) { - const svgRef = useRef<SVGSVGElement>(null); - const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null); - const isShiftPressedRef = useShiftKey(); - const latestValueRef = useRef(value); - - latestValueRef.current = value; - - function getPointerPosition({ - event, - }: { - event: PointerEvent; - }): { x: number; y: number } { - const svg = svgRef.current; - if (!svg) return { x: 0, y: 0 }; - const rect = svg.getBoundingClientRect(); - const scale = SVG_WIDTH / rect.width; - return { - x: (event.clientX - rect.left) * scale, - y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height), - }; - } - - function onHandlePointerDown({ handle }: { handle: BezierHandle }) { - return (event: PointerEvent<SVGCircleElement>) => { - event.preventDefault(); - event.stopPropagation(); - setActiveHandle(handle); - event.currentTarget.setPointerCapture(event.pointerId); - }; - } - - function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) { - if (!activeHandle) return; - const pointerPos = getPointerPosition({ event }); - const x = fromSvgX({ svgX: pointerPos.x }); - const y = snap({ - value: fromSvgY({ svgY: pointerPos.y }), - targets: SNAP_TARGETS, - isEnabled: !isShiftPressedRef.current, - }); - const next: NormalizedCubicBezier = [...value]; - if (activeHandle === "c1") { - next[0] = x; - next[1] = y; - } else { - next[2] = x; - next[3] = y; - } - latestValueRef.current = next; - onChange?.(next); - } - - function onPointerUp() { - if (!activeHandle) return; - setActiveHandle(null); - onChangeEnd?.(latestValueRef.current); - } - - function onPointerCancel() { - if (!activeHandle) return; - setActiveHandle(null); - onCancel?.(); - } - - const path = curvePath({ curve: value }); - const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) }; - const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) }; - const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) }; - const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) }; - const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) }; - const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) }; - - return ( - <svg - ref={svgRef} - viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`} - className="bg-foreground/3 w-full cursor-crosshair select-none" - onPointerMove={(event) => onPointerMove({ event })} - onPointerUp={onPointerUp} - onPointerCancel={onPointerCancel} - > - <title>Bezier curve editor</title> - <line - x1={p0.x} - y1={p0.y} - x2={p1.x} - y2={p1.y} - className="stroke-foreground/8" - strokeWidth={1} - strokeDasharray="3 3" - /> - <line - x1={p0.x} - y1={p0.y} - x2={c1Clamped.x} - y2={c1Clamped.y} - className="stroke-primary/30" - strokeWidth={1} - /> - <line - x1={p1.x} - y1={p1.y} - x2={c2Clamped.x} - y2={c2Clamped.y} - className="stroke-primary/30" - strokeWidth={1} - /> - <path - d={path} - fill="none" - className="stroke-primary" - strokeWidth={2} - strokeLinecap="round" - /> - <circle - cx={p0.x} - cy={p0.y} - r={ENDPOINT_RADIUS} - className="fill-foreground/20" - /> - <circle - cx={p1.x} - cy={p1.y} - r={ENDPOINT_RADIUS} - className="fill-foreground/20" - /> - <circle - cx={c1Clamped.x} - cy={c1Clamped.y} - r={HANDLE_RADIUS} - className={cn( - "fill-primary cursor-grab", - activeHandle === "c1" && "cursor-grabbing", - )} - onPointerDown={onHandlePointerDown({ handle: "c1" })} - /> - <circle - cx={c2Clamped.x} - cy={c2Clamped.y} - r={HANDLE_RADIUS} - className={cn( - "fill-primary cursor-grab", - activeHandle === "c2" && "cursor-grabbing", - )} - onPointerDown={onHandlePointerDown({ handle: "c2" })} - /> - </svg> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts deleted file mode 100644 index 684ac7c9..00000000 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import { useSyncExternalStore } from "react"; -import { generateUUID } from "@/utils/id"; -import type { NormalizedCubicBezier } from "@/lib/animation/types"; -import type { EasingPreset } from "./easing-presets"; - -const STORAGE_KEY = "graph-editor-presets"; - -let cachedPresets: EasingPreset[] | null = null; -const listeners = new Set<() => void>(); - -function isValidPresetArray(value: unknown): value is EasingPreset[] { - return ( - Array.isArray(value) && - value.every( - (item) => - typeof item === "object" && - item !== null && - typeof item.id === "string" && - typeof item.label === "string" && - Array.isArray(item.value) && - item.value.length === 4 && - item.value.every((number: unknown) => typeof number === "number"), - ) - ); -} - -function readFromStorage(): EasingPreset[] { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed: unknown = JSON.parse(raw); - return isValidPresetArray(parsed) ? parsed : []; - } catch { - // Silently recover — corrupted localStorage shouldn't crash the editor - return []; - } -} - -function writeToStorage({ presets }: { presets: EasingPreset[] }): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); -} - -function getSnapshot(): EasingPreset[] { - cachedPresets ??= readFromStorage(); - return cachedPresets; -} - -function getServerSnapshot(): EasingPreset[] { - return []; -} - -function notify(): void { - cachedPresets = null; - for (const listener of listeners) { - listener(); - } -} - -function onStorageChange(event: StorageEvent): void { - if (event.key === STORAGE_KEY) notify(); -} - -function subscribe(listener: () => void): () => void { - if (listeners.size === 0 && typeof window !== "undefined") { - window.addEventListener("storage", onStorageChange); - } - listeners.add(listener); - return () => { - listeners.delete(listener); - if (listeners.size === 0 && typeof window !== "undefined") { - window.removeEventListener("storage", onStorageChange); - } - }; -} - -export function useCustomPresets(): EasingPreset[] { - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -} - -export function savePreset({ value }: { value: NormalizedCubicBezier }): void { - const current = getSnapshot(); - writeToStorage({ - presets: [ - ...current, - { - id: generateUUID(), - label: `Custom ${current.length + 1}`, - value, - isCustom: true, - }, - ], - }); - notify(); -} - -export function removePreset({ id }: { id: string }): void { - writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) }); - notify(); -} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts deleted file mode 100644 index 6ccc9071..00000000 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { NormalizedCubicBezier } from "@/lib/animation/types"; - -export const PRESET_MATCH_TOLERANCE = 0.02; - -export interface EasingPreset { - id: string; - label: string; - value: NormalizedCubicBezier; - isCustom?: boolean; -} - -export const BUILTIN_PRESETS: EasingPreset[] = [ - { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] }, - { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] }, - { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] }, - { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] }, - { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] }, - { id: "linear", label: "Linear", value: [0, 0, 1, 1] }, -]; diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx deleted file mode 100644 index 733195ed..00000000 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx +++ /dev/null @@ -1,334 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Popover, PopoverContent } from "@/components/ui/popover"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowDown01Icon, - Delete02Icon, - PlusSignIcon, -} from "@hugeicons/core-free-icons"; -import { getBezierPoint } from "@/lib/animation/bezier"; -import type { NormalizedCubicBezier } from "@/lib/animation/types"; -import type { GraphEditorComponentOption } from "./session"; -import { - BUILTIN_PRESETS, - PRESET_MATCH_TOLERANCE, - type EasingPreset, -} from "./easing-presets"; -import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store"; -import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph"; - -const COLLAPSED_MAX = 6; -const THUMB_SEGMENTS = 24; -const THUMB_WIDTH = 40; -const THUMB_HEIGHT = 22; -const THUMB_PADDING_X = 4; -const THUMB_PADDING_Y = 3; -const COLLAPSED_GRID_MAX_HEIGHT = 120; -const EXPANDED_GRID_MAX_HEIGHT = 240; - -export function GraphEditorPopover({ - children, - side, - open, - onOpenChange, - value, - message, - componentOptions, - activeComponentKey, - onActiveComponentKeyChange, - onPreviewValue, - onCommitValue, - onCancelPreview, -}: { - children: React.ReactNode; - side?: "top" | "bottom" | "left" | "right"; - open?: boolean; - onOpenChange?: (open: boolean) => void; - value: NormalizedCubicBezier | null; - message: string; - componentOptions: GraphEditorComponentOption[]; - activeComponentKey: string | null; - onActiveComponentKeyChange?: (componentKey: string) => void; - onPreviewValue?: (value: NormalizedCubicBezier) => void; - onCommitValue?: (value: NormalizedCubicBezier) => void; - onCancelPreview?: () => void; -}) { - const [isExpanded, setIsExpanded] = useState(false); - const custom = useCustomPresets(); - const allPresets = [...BUILTIN_PRESETS, ...custom]; - const canEdit = value !== null; - const activePresetId = - value == null - ? null - : (allPresets.find((preset) => - preset.value.every( - (presetValue, index) => - Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE, - ), - )?.id ?? null); - - return ( - <Popover - open={open} - onOpenChange={(nextOpen) => { - if (!nextOpen) { - onCancelPreview?.(); - } - onOpenChange?.(nextOpen); - }} - > - {children} - <PopoverContent - side={side} - sideOffset={8} - className="w-60 overflow-hidden px-0" - > - {componentOptions.length > 1 && ( - <div className="border-b px-3 py-2"> - <div className="bg-muted/40 inline-flex rounded-md p-0.5"> - {componentOptions.map((component) => ( - <button - key={component.key} - type="button" - onClick={() => onActiveComponentKeyChange?.(component.key)} - className={cn( - "cursor-pointer rounded-sm px-2 py-1 text-xs font-medium", - activeComponentKey === component.key - ? "bg-background text-foreground shadow-xs" - : "text-muted-foreground hover:text-foreground", - )} - > - {component.label} - </button> - ))} - </div> - </div> - )} - - <div className="px-3 py-3"> - {value ? ( - <BezierGraph - value={value} - onChange={onPreviewValue} - onChangeEnd={onCommitValue} - onCancel={onCancelPreview} - /> - ) : ( - <GraphEditorEmptyState message={message} /> - )} - </div> - - <Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2"> - <TabsList className="px-3"> - <TabsTrigger value="presets" className="text-xs"> - Presets - </TabsTrigger> - <TabsTrigger value="saved" className="text-xs"> - Saved - </TabsTrigger> - </TabsList> - <TabsContent value="presets" className="px-3 pb-0"> - <ExpandableGrid - isExpanded={isExpanded} - shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX} - onExpand={() => setIsExpanded(true)} - > - {BUILTIN_PRESETS.map((preset) => ( - <PresetItem - key={preset.id} - preset={preset} - isActive={activePresetId === preset.id} - disabled={!canEdit} - onSelect={() => onCommitValue?.(preset.value)} - /> - ))} - </ExpandableGrid> - </TabsContent> - <TabsContent value="saved" className="px-3"> - <div className="grid grid-cols-3 gap-1"> - {custom.map((preset) => ( - <PresetItem - key={preset.id} - preset={preset} - isActive={activePresetId === preset.id} - disabled={!canEdit} - onSelect={() => onCommitValue?.(preset.value)} - onDelete={() => removePreset({ id: preset.id })} - /> - ))} - <button - type="button" - onClick={() => value && savePreset({ value })} - disabled={!canEdit} - className={cn( - "text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1", - canEdit - ? "hover:bg-foreground/5 cursor-pointer" - : "cursor-not-allowed opacity-50", - )} - > - <div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed"> - <HugeiconsIcon - icon={PlusSignIcon} - className="size-3.5 opacity-40" - /> - </div> - <span className="text-[10px] leading-tight">Save</span> - </button> - </div> - </TabsContent> - </Tabs> - </PopoverContent> - </Popover> - ); -} - -function GraphEditorEmptyState({ message }: { message: string }) { - return ( - <div - style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }} - className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed" - > - {message} - </div> - ); -} - -function ExpandableGrid({ - children, - isExpanded, - shouldExpand, - onExpand, -}: { - children: React.ReactNode; - isExpanded: boolean; - shouldExpand: boolean; - onExpand: () => void; -}) { - const gridStyle = shouldExpand - ? isExpanded - ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const } - : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const } - : undefined; - - return ( - <div className="relative"> - <div className="grid grid-cols-3 gap-1" style={gridStyle}> - {children} - </div> - {!isExpanded && shouldExpand && ( - <div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b"> - <Button - variant="ghost" - size="icon" - className="size-5" - onClick={onExpand} - > - <HugeiconsIcon - icon={ArrowDown01Icon} - className="text-muted-foreground size-3" - /> - </Button> - </div> - )} - </div> - ); -} - -function PresetItem({ - preset, - isActive, - onSelect, - onDelete, - disabled, -}: { - preset: EasingPreset; - isActive: boolean; - onSelect: () => void; - onDelete?: () => void; - disabled?: boolean; -}) { - return ( - <button - type="button" - onClick={onSelect} - disabled={disabled} - className={cn( - "group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1", - disabled - ? "cursor-not-allowed opacity-50" - : "hover:bg-foreground/5 cursor-pointer", - isActive && "bg-primary/5! text-primary", - )} - > - <div - className={cn( - "flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5", - isActive && "bg-primary/5!", - )} - > - <CurveThumb value={preset.value} /> - </div> - <span - className={cn( - "text-[10px] leading-tight", - isActive ? "text-primary" : "text-muted-foreground", - )} - > - {preset.label} - </span> - {onDelete && ( - <Button - variant="destructive" - size="icon" - className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex" - onClick={(event) => { - event.stopPropagation(); - onDelete(); - }} - > - <HugeiconsIcon icon={Delete02Icon} /> - </Button> - )} - </button> - ); -} - -function toThumbX({ value }: { value: number }) { - return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2); -} - -function toThumbY({ value }: { value: number }) { - return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2); -} - -function CurveThumb({ value }: { value: NormalizedCubicBezier }) { - const points: string[] = []; - for (let i = 0; i <= THUMB_SEGMENTS; i++) { - const progress = i / THUMB_SEGMENTS; - const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) }); - const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) }); - points.push(`${x},${y}`); - } - return ( - <svg - width={THUMB_WIDTH} - height={THUMB_HEIGHT} - viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`} - > - <title>Curve preset preview</title> - <path - d={`M${points.join("L")}`} - fill="none" - className="stroke-current" - strokeWidth={1.5} - strokeLinecap="round" - /> - </svg> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts deleted file mode 100644 index cc0fb070..00000000 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts +++ /dev/null @@ -1,704 +0,0 @@ -import { - getCurveHandlesForNormalizedCubicBezier, - getEditableScalarChannels, - getEasingModeForKind, - getNormalizedCubicBezierForScalarSegment, - getScalarKeyframeContext, - updateScalarKeyframeCurve, -} from "@/lib/animation"; -import type { - AnimationPath, - ElementAnimations, - NormalizedCubicBezier, - ScalarCurveKeyframePatch, - ScalarGraphKeyframeContext, - SelectedKeyframeRef, -} from "@/lib/animation/types"; -import type { SceneTracks, TimelineElement } from "@/lib/timeline"; - -const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1]; -const FLAT_VALUE_EPSILON = 1e-6; -const LINEAR_CURVE_EPSILON = 1e-6; - -export type GraphEditorUnavailableReason = - | "no-keyframe-selected" - | "multiple-keyframes-selected" - | "selected-keyframes-span-multiple-elements" - | "selected-keyframes-are-not-adjacent" - | "selected-properties-have-no-shared-component" - | "selected-element-missing" - | "selected-element-has-no-animations" - | "selected-keyframe-has-no-scalar-channel" - | "selected-keyframe-missing-on-channel" - | "selected-keyframe-has-no-next-segment" - | "selected-segment-is-hold" - | "selected-segment-is-flat"; - -export interface GraphEditorComponentOption { - key: string; - label: string; -} - -interface GraphEditorPropertyOption { - key: string; - label: string; - context: ScalarGraphKeyframeContext; - allContexts: ScalarGraphKeyframeContext[]; -} - -export interface GraphEditorResolvedSegment { - propertyPath: SelectedKeyframeRef["propertyPath"]; - keyframeId: string; - context: ScalarGraphKeyframeContext; - allContexts: ScalarGraphKeyframeContext[]; - cubicBezier: NormalizedCubicBezier; - referenceSpanValue: number; -} - -interface GraphEditorBaseSelectionState { - componentOptions: GraphEditorComponentOption[]; - activeComponentKey: string | null; - message: string; -} - -export interface GraphEditorUnavailableState - extends GraphEditorBaseSelectionState { - status: "unavailable"; - reason: GraphEditorUnavailableReason; -} - -export interface GraphEditorReadyState extends GraphEditorBaseSelectionState { - status: "ready"; - trackId: string; - elementId: string; - element: TimelineElement; - segments: GraphEditorResolvedSegment[]; - cubicBezier: NormalizedCubicBezier; -} - -export type GraphEditorSelectionState = - | GraphEditorUnavailableState - | GraphEditorReadyState; - -export interface GraphEditorCurvePatch { - keyframeId: string; - patch: ScalarCurveKeyframePatch; -} - -function createUnavailableState({ - reason, - message, - componentOptions = [], - activeComponentKey = null, -}: { - reason: GraphEditorUnavailableReason; - message: string; - componentOptions?: GraphEditorComponentOption[]; - activeComponentKey?: string | null; -}): GraphEditorUnavailableState { - return { - status: "unavailable", - reason, - message, - componentOptions, - activeComponentKey, - }; -} - -function findElementByKeyframe({ - tracks, - keyframe, -}: { - tracks: SceneTracks; - keyframe: SelectedKeyframeRef; -}): { element: TimelineElement; trackId: string; elementId: string } | null { - for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { - if (track.id !== keyframe.trackId) { - continue; - } - - const element = track.elements.find( - (trackElement) => trackElement.id === keyframe.elementId, - ); - if (!element) { - return null; - } - - return { - element, - trackId: track.id, - elementId: element.id, - }; - } - - return null; -} - -function findKeyframeTime({ - animations, - propertyPath, - keyframeId, -}: { - animations: ElementAnimations; - propertyPath: AnimationPath; - keyframeId: string; -}): number | null { - const binding = animations.bindings[propertyPath]; - if (!binding) return null; - - for (const component of binding.components) { - const channel = animations.channels[component.channelId]; - if (channel?.kind !== "scalar") continue; - const key = channel.keys.find((k) => k.id === keyframeId); - if (key !== undefined) return key.time; - } - - return null; -} - -function groupSelectedKeyframesByProperty({ - selectedKeyframes, -}: { - selectedKeyframes: SelectedKeyframeRef[]; -}) { - const groups = new Map< - string, - { - trackId: string; - elementId: string; - propertyPath: SelectedKeyframeRef["propertyPath"]; - keyframes: SelectedKeyframeRef[]; - } - >(); - - for (const keyframe of selectedKeyframes) { - const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`; - const existingGroup = groups.get(groupKey); - if (existingGroup) { - existingGroup.keyframes.push(keyframe); - continue; - } - - groups.set(groupKey, { - trackId: keyframe.trackId, - elementId: keyframe.elementId, - propertyPath: keyframe.propertyPath, - keyframes: [keyframe], - }); - } - - return [...groups.values()]; -} - -function getComponentLabel({ componentKey }: { componentKey: string }): string { - switch (componentKey) { - case "value": - return "Value"; - default: - return componentKey.toUpperCase(); - } -} - -/** - * Returns the absolute value span of the nearest non-flat adjacent segment, - * used as the Y-axis scale when editing a flat segment in the graph editor. - * Falls back to 1.0 if all surrounding segments are also flat. - */ -function getReferenceSpanValue({ - context, -}: { - context: ScalarGraphKeyframeContext; -}): number { - const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time); - const leftIndex = sorted.findIndex((k) => k.id === context.keyframe.id); - const rightIndex = context.nextKey - ? sorted.findIndex((k) => k.id === context.nextKey?.id) - : -1; - - for (let i = leftIndex - 1; i >= 0; i--) { - const span = Math.abs(sorted[i + 1].value - sorted[i].value); - if (span > FLAT_VALUE_EPSILON) return span; - } - - if (rightIndex !== -1) { - for (let i = rightIndex; i < sorted.length - 1; i++) { - const span = Math.abs(sorted[i + 1].value - sorted[i].value); - if (span > FLAT_VALUE_EPSILON) return span; - } - } - - return 1.0; -} - -interface GraphEditorPropertySelection { - propertyPath: SelectedKeyframeRef["propertyPath"]; - keyframeId: string; - secondaryKeyframeId: string | null; - options: GraphEditorPropertyOption[]; -} - -function resolvePropertySelection({ - element, - propertyKeyframes, -}: { - element: TimelineElement; - propertyKeyframes: ReturnType< - typeof groupSelectedKeyframesByProperty - >[number]; -}): - | GraphEditorPropertySelection - | { - reason: GraphEditorUnavailableReason; - message: string; - } { - if (propertyKeyframes.keyframes.length > 2) { - return { - reason: "multiple-keyframes-selected", - message: "Select at most two adjacent keyframes per property.", - }; - } - - if (!element.animations) { - return { - reason: "selected-element-has-no-animations", - message: "The selected keyframe has no editable graph.", - }; - } - - const scalarResult = getEditableScalarChannels({ - animations: element.animations, - propertyPath: propertyKeyframes.propertyPath, - }); - if (!scalarResult || scalarResult.channels.length === 0) { - return { - reason: "selected-keyframe-has-no-scalar-channel", - message: "The selected keyframe has no editable graph channel.", - }; - } - - const primaryKeyframe = propertyKeyframes.keyframes[0]; - let resolvedKeyframeId = primaryKeyframe.keyframeId; - let secondaryKeyframeId = - propertyKeyframes.keyframes.length === 2 - ? propertyKeyframes.keyframes[1].keyframeId - : null; - - if (secondaryKeyframeId !== null) { - const time1 = findKeyframeTime({ - animations: element.animations, - propertyPath: propertyKeyframes.propertyPath, - keyframeId: primaryKeyframe.keyframeId, - }); - const time2 = findKeyframeTime({ - animations: element.animations, - propertyPath: propertyKeyframes.propertyPath, - keyframeId: secondaryKeyframeId, - }); - if (time2 !== null && (time1 === null || time2 < time1)) { - resolvedKeyframeId = secondaryKeyframeId; - secondaryKeyframeId = primaryKeyframe.keyframeId; - } - } - - const { binding: resolvedBinding, channels: scalarChannels } = scalarResult; - const easingMode = getEasingModeForKind(resolvedBinding.kind); - const contexts = scalarChannels.flatMap((channel) => { - const context = getScalarKeyframeContext({ - animations: element.animations, - propertyPath: propertyKeyframes.propertyPath, - componentKey: channel.componentKey, - keyframeId: resolvedKeyframeId, - }); - if (!context) { - return []; - } - - return [ - { - context, - option: { - key: channel.componentKey, - label: getComponentLabel({ componentKey: channel.componentKey }), - }, - }, - ]; - }); - - if (contexts.length === 0) { - return { - reason: "selected-keyframe-missing-on-channel", - message: "The selected keyframe is not editable as a graph segment.", - }; - } - - // For shared-easing bindings (e.g. color), all components always use the same - // curve. Collapse them to a single "value" option so the key is compatible with - // single-component scalar bindings (e.g. opacity), enabling mixed selections. - const options = - easingMode === "shared" - ? [ - { - key: "value", - label: "Curve", - context: contexts[0].context, - allContexts: contexts.map(({ context }) => context), - }, - ] - : contexts.map(({ context, option }) => ({ - key: option.key, - label: option.label, - context, - allContexts: [context], - })); - - return { - propertyPath: propertyKeyframes.propertyPath, - keyframeId: resolvedKeyframeId, - secondaryKeyframeId, - options, - }; -} - -function isLinearCurve({ - cubicBezier, -}: { - cubicBezier: NormalizedCubicBezier; -}): boolean { - return ( - Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON && - Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON && - Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON && - Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON - ); -} - -function resolveSegmentForOption({ - propertySelection, - componentKey, -}: { - propertySelection: GraphEditorPropertySelection; - componentKey: string; -}): - | { - segment: GraphEditorResolvedSegment; - } - | { - reason: GraphEditorUnavailableReason; - message: string; - } { - const option = propertySelection.options.find( - (propertyOption) => propertyOption.key === componentKey, - ); - if (!option) { - return { - reason: "selected-properties-have-no-shared-component", - message: "Selected properties do not share a graph-editable channel.", - }; - } - - if (!option.context.nextKey) { - return { - reason: "selected-keyframe-has-no-next-segment", - message: "Select a keyframe that has an outgoing segment.", - }; - } - - if ( - propertySelection.secondaryKeyframeId !== null && - option.context.nextKey.id !== propertySelection.secondaryKeyframeId - ) { - return { - reason: "selected-keyframes-are-not-adjacent", - message: "Selected keyframes must be adjacent on each property.", - }; - } - - if (option.context.keyframe.segmentToNext === "step") { - return { - reason: "selected-segment-is-hold", - message: "Hold segments have a fixed value - easing has no effect here.", - }; - } - - const referenceSpanValue = getReferenceSpanValue({ context: option.context }); - const cubicBezier = - option.context.keyframe.segmentToNext === "linear" - ? GRAPH_LINEAR_CURVE - : getNormalizedCubicBezierForScalarSegment({ - leftKey: option.context.keyframe, - rightKey: option.context.nextKey, - referenceSpanValue, - }); - if (!cubicBezier) { - return { - reason: "selected-segment-is-flat", - message: - "Cannot edit a segment where both keyframes are at the same time.", - }; - } - - return { - segment: { - propertyPath: propertySelection.propertyPath, - keyframeId: propertySelection.keyframeId, - context: option.context, - allContexts: option.allContexts, - cubicBezier, - referenceSpanValue, - }, - }; -} - -export function resolveGraphEditorSelectionState({ - tracks, - selectedKeyframes, - preferredComponentKey, -}: { - tracks: SceneTracks; - selectedKeyframes: SelectedKeyframeRef[]; - preferredComponentKey?: string | null; -}): GraphEditorSelectionState { - if (selectedKeyframes.length === 0) { - return createUnavailableState({ - reason: "no-keyframe-selected", - message: "Select a keyframe to edit its curve.", - }); - } - - const propertyKeyframes = groupSelectedKeyframesByProperty({ - selectedKeyframes, - }); - const primaryKeyframe = propertyKeyframes[0]?.keyframes[0]; - if (!primaryKeyframe) { - return createUnavailableState({ - reason: "no-keyframe-selected", - message: "Select a keyframe to edit its curve.", - }); - } - - const selectedElement = findElementByKeyframe({ - tracks, - keyframe: primaryKeyframe, - }); - if (!selectedElement) { - return createUnavailableState({ - reason: "selected-element-missing", - message: "The selected keyframe could not be resolved.", - }); - } - - const spansMultipleElements = propertyKeyframes.some( - (propertySelection) => - propertySelection.trackId !== selectedElement.trackId || - propertySelection.elementId !== selectedElement.elementId, - ); - if (spansMultipleElements) { - return createUnavailableState({ - reason: "selected-keyframes-span-multiple-elements", - message: "Selected keyframes must be on the same element.", - }); - } - - const propertySelections = propertyKeyframes.map((propertySelection) => - resolvePropertySelection({ - element: selectedElement.element, - propertyKeyframes: propertySelection, - }), - ); - const unavailablePropertySelection = propertySelections.find( - (propertySelection) => "reason" in propertySelection, - ); - if ( - unavailablePropertySelection && - "reason" in unavailablePropertySelection - ) { - return createUnavailableState({ - reason: unavailablePropertySelection.reason, - message: unavailablePropertySelection.message, - }); - } - - const resolvedPropertySelections = propertySelections.filter( - (propertySelection): propertySelection is GraphEditorPropertySelection => - !("reason" in propertySelection), - ); - const sharedComponentOptions = - resolvedPropertySelections[0]?.options.filter((componentOption) => - resolvedPropertySelections.every((propertySelection) => - propertySelection.options.some( - (option) => option.key === componentOption.key, - ), - ), - ) ?? []; - const componentOptions = sharedComponentOptions.map(({ key, label }) => ({ - key, - label, - })); - if (componentOptions.length === 0) { - return createUnavailableState({ - reason: "selected-properties-have-no-shared-component", - message: "Selected properties do not share a graph-editable channel.", - }); - } - - // Try each component option in preference order (preferred first, then the rest) - // and stop at the first key where every property resolves to a valid segment. - // This single pass both selects the active key and produces the segment list. - const candidateKeys = [ - ...(preferredComponentKey && - componentOptions.some((option) => option.key === preferredComponentKey) - ? [preferredComponentKey] - : []), - ...componentOptions - .filter((option) => option.key !== preferredComponentKey) - .map((option) => option.key), - ]; - - let activeComponentKey = componentOptions[0].key; - let segmentResults: ReturnType<typeof resolveSegmentForOption>[] = []; - - for (const candidateKey of candidateKeys) { - const results = resolvedPropertySelections.map((propertySelection) => - resolveSegmentForOption({ - propertySelection, - componentKey: candidateKey, - }), - ); - activeComponentKey = candidateKey; - segmentResults = results; - if (results.every((result) => "segment" in result)) { - break; - } - } - - const unavailableSegment = segmentResults.find( - (result) => !("segment" in result), - ); - if (unavailableSegment && !("segment" in unavailableSegment)) { - return createUnavailableState({ - reason: unavailableSegment.reason, - message: unavailableSegment.message, - componentOptions, - activeComponentKey, - }); - } - - const segments = segmentResults.flatMap((result) => - "segment" in result ? [result.segment] : [], - ); - const primarySegment = segments[0]; - if (!primarySegment) { - return createUnavailableState({ - reason: "selected-keyframe-missing-on-channel", - message: "The selected keyframe is not editable as a graph segment.", - componentOptions, - activeComponentKey, - }); - } - - return { - status: "ready", - message: - segments.length === 1 - ? "Edit graph" - : `Edit graph for ${segments.length} properties`, - componentOptions, - activeComponentKey, - trackId: selectedElement.trackId, - elementId: selectedElement.elementId, - element: selectedElement.element, - segments, - cubicBezier: primarySegment.cubicBezier, - }; -} - -export function buildGraphEditorCurvePatches({ - context, - cubicBezier, - referenceSpanValue, -}: { - context: ScalarGraphKeyframeContext; - cubicBezier: NormalizedCubicBezier; - referenceSpanValue: number; -}): GraphEditorCurvePatch[] | null { - if (!context.nextKey) { - return null; - } - - if (isLinearCurve({ cubicBezier })) { - return [ - { - keyframeId: context.keyframe.id, - patch: { - segmentToNext: "linear", - rightHandle: null, - }, - }, - { - keyframeId: context.nextKey.id, - patch: { - leftHandle: null, - }, - }, - ]; - } - - const handles = getCurveHandlesForNormalizedCubicBezier({ - leftKey: context.keyframe, - rightKey: context.nextKey, - cubicBezier, - referenceSpanValue, - }); - if (!handles) { - return null; - } - - return [ - { - keyframeId: context.keyframe.id, - patch: { - segmentToNext: "bezier", - rightHandle: handles.rightHandle, - }, - }, - { - keyframeId: context.nextKey.id, - patch: { - leftHandle: handles.leftHandle, - }, - }, - ]; -} - -export function applyGraphEditorCurvePreview({ - animations, - context, - cubicBezier, - referenceSpanValue, -}: { - animations: ElementAnimations | undefined; - context: ScalarGraphKeyframeContext; - cubicBezier: NormalizedCubicBezier; - referenceSpanValue: number; -}): ElementAnimations | undefined { - const patches = buildGraphEditorCurvePatches({ - context, - cubicBezier, - referenceSpanValue, - }); - if (!patches) { - return animations; - } - - return patches.reduce<ElementAnimations | undefined>( - (nextAnimations, { keyframeId, patch }) => - updateScalarKeyframeCurve({ - animations: nextAnimations, - propertyPath: context.propertyPath, - componentKey: context.componentKey, - keyframeId, - patch, - }), - animations, - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts deleted file mode 100644 index 51d5a0ff..00000000 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts +++ /dev/null @@ -1,178 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { registerCanceller } from "@/lib/cancel-interaction"; -import type { NormalizedCubicBezier } from "@/lib/animation/types"; -import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; -import { - applyGraphEditorCurvePreview, - buildGraphEditorCurvePatches, - resolveGraphEditorSelectionState, - type GraphEditorSelectionState, -} from "./session"; - -export function useGraphEditorController() { - const editor = useEditor(); - const renderTracks = useEditor( - (currentEditor) => - currentEditor.timeline.getPreviewTracks() ?? - currentEditor.scenes.getActiveScene().tracks, - ); - const { selectedKeyframes } = useKeyframeSelection(); - const [open, setOpen] = useState(false); - const [activeComponentKey, setActiveComponentKey] = useState<string | null>( - null, - ); - const hasPreviewRef = useRef(false); - - const state = useMemo<GraphEditorSelectionState>( - () => - resolveGraphEditorSelectionState({ - tracks: renderTracks, - selectedKeyframes, - preferredComponentKey: activeComponentKey, - }), - [activeComponentKey, renderTracks, selectedKeyframes], - ); - - const stateKey = - state.status === "ready" - ? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments - .map( - (segment) => - `${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`, - ) - .join("|")}` - : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`; - const previousStateKeyRef = useRef(stateKey); - - const discardPreview = useCallback(() => { - if (!hasPreviewRef.current) { - return; - } - - editor.timeline.discardPreview(); - hasPreviewRef.current = false; - }, [editor]); - - useEffect(() => { - if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) { - discardPreview(); - } - - previousStateKeyRef.current = stateKey; - }, [discardPreview, stateKey]); - - useEffect(() => { - if (!open) { - return; - } - - return registerCanceller({ - fn: () => { - discardPreview(); - setOpen(false); - }, - }); - }, [discardPreview, open]); - - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - if (!nextOpen) { - discardPreview(); - } - - setOpen(nextOpen); - }, - [discardPreview], - ); - - const handleActiveComponentKeyChange = useCallback( - (nextComponentKey: string) => { - discardPreview(); - setActiveComponentKey(nextComponentKey); - }, - [discardPreview], - ); - - const handlePreviewValue = useCallback( - (nextValue: NormalizedCubicBezier) => { - if (state.status !== "ready") { - return; - } - - const nextAnimations = state.segments.reduce( - (animations, segment) => - segment.allContexts.reduce( - (nextAnimationsForSegment, context) => - applyGraphEditorCurvePreview({ - animations: nextAnimationsForSegment, - context, - cubicBezier: nextValue, - referenceSpanValue: segment.referenceSpanValue, - }), - animations, - ), - state.element.animations, - ); - editor.timeline.previewElements({ - updates: [ - { - trackId: state.trackId, - elementId: state.elementId, - updates: { animations: nextAnimations }, - }, - ], - }); - hasPreviewRef.current = true; - }, - [editor, state], - ); - - const handleCommitValue = useCallback( - (nextValue: NormalizedCubicBezier) => { - if (state.status !== "ready") { - return; - } - - editor.timeline.updateKeyframeCurves({ - keyframes: state.segments.flatMap((segment) => { - const patches = buildGraphEditorCurvePatches({ - context: segment.context, - cubicBezier: nextValue, - referenceSpanValue: segment.referenceSpanValue, - }); - if (!patches) { - return []; - } - - return segment.allContexts.flatMap((context) => - patches.map(({ keyframeId, patch }) => ({ - trackId: state.trackId, - elementId: state.elementId, - propertyPath: segment.propertyPath, - componentKey: context.componentKey, - keyframeId, - patch, - })), - ); - }), - }); - hasPreviewRef.current = false; - }, - [editor, state], - ); - - return { - open, - onOpenChange: handleOpenChange, - canOpen: state.status === "ready", - tooltip: state.status === "ready" ? "Open graph editor" : state.message, - state, - onActiveComponentKeyChange: handleActiveComponentKeyChange, - onPreviewValue: handlePreviewValue, - onCommitValue: handleCommitValue, - onCancelPreview: discardPreview, - }; -} diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx deleted file mode 100644 index 20e9faf1..00000000 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ /dev/null @@ -1,944 +0,0 @@ -"use client"; - -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Delete02Icon, - MagicWand05Icon, - MusicNote03Icon, - TaskAdd02Icon, - TextIcon, - ViewIcon, - ViewOffSlashIcon, - VolumeHighIcon, - VolumeOffIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; -import { OcShapesIcon, OcVideoIcon } from "@/components/icons"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import type { ElementDragState, DropTarget } from "@/lib/timeline"; -import { TimelineTrackContent } from "./timeline-track"; -import { TimelinePlayhead } from "./timeline-playhead"; -import { SelectionBox } from "@/lib/selection/selection-box"; -import { useBoxSelect } from "@/lib/selection/hooks/use-box-select"; -import { SnapIndicator } from "./snap-indicator"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import type { TimelineTrack } from "@/lib/timeline"; -import { - TIMELINE_SCROLLBAR_SIZE_PX, - TIMELINE_CONTENT_TOP_PADDING_PX, - TIMELINE_TRACK_GAP_PX, - TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX, - KEYFRAME_LANE_HEIGHT_PX, -} from "./layout"; -import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; -import { - canTrackHaveAudio, - canTrackBeHidden, - getTimelineZoomMin, - getTimelinePaddingPx, -} from "@/lib/timeline"; -import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils"; -import { - getTrackHeight, - getCumulativeHeightBefore, - getTotalTracksHeight, -} from "./track-layout"; -import { SELECTED_TRACK_ROW_CLASS } from "./theme"; -import { - computeTrackExpansionHeight, - getTrackExpandedRows, - getPropertyLabel, - type ExpandedRow, -} from "./expanded-layout"; -import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; -import { TimelineToolbar } from "./timeline-toolbar"; -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; -import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; -import { TimelineRuler } from "./timeline-ruler"; -import { TimelineBookmarksRow } from "./bookmarks"; -import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag"; -import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom"; -import { useTimelineResize } from "@/hooks/timeline/use-timeline-resize"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useEditor } from "@/hooks/use-editor"; -import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; -import { DragLine } from "./drag-line"; -import { invokeAction } from "@/lib/actions"; -import { resolveTimelineElementIntersections } from "./selection-hit-testing"; -import { cn } from "@/utils/ui"; - -const TRACKS_CONTAINER_MAX_HEIGHT = 800; -const FALLBACK_CONTAINER_WIDTH = 1000; -const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; -const TRACK_ICONS: Record<TimelineTrack["type"], ReactNode> = { - video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />, - text: ( - <HugeiconsIcon - icon={TextIcon} - className="text-muted-foreground size-4 shrink-0" - /> - ), - audio: ( - <HugeiconsIcon - icon={MusicNote03Icon} - className="text-muted-foreground size-4 shrink-0" - /> - ), - graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />, - effect: ( - <HugeiconsIcon - icon={MagicWand05Icon} - className="text-muted-foreground size-4 shrink-0" - /> - ), -}; - -export function Timeline() { - const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); - const { - selectedElements, - clearElementSelection, - setElementSelection, - mergeElementsIntoSelection, - } = useElementSelection(); - const editor = useEditor(); - const timeline = editor.timeline; - const scene = useEditor((currentEditor) => - currentEditor.scenes.getActiveSceneOrNull(), - ); - const tracks = useMemo<TimelineTrack[]>( - () => - scene - ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] - : [], - [scene], - ); - const mainTrackId = scene?.tracks.main.id ?? null; - const seek = (time: number) => editor.playback.seek({ time }); - - const timelineRef = useRef<HTMLDivElement>(null); - const timelineHeaderRef = useRef<HTMLDivElement>(null); - const rulerRef = useRef<HTMLDivElement>(null); - const rulerScrollRef = useRef<HTMLDivElement>(null); - const tracksContainerRef = useRef<HTMLDivElement>(null); - const tracksScrollRef = useRef<HTMLDivElement>(null); - const trackLabelsRef = useRef<HTMLDivElement>(null); - const playheadRef = useRef<HTMLDivElement>(null); - const trackLabelsScrollRef = useRef<HTMLDivElement>(null); - - const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( - null, - ); - - const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { - setCurrentSnapPoint(snapPoint); - }, []); - - const timelineDuration = timeline.getTotalDuration() || 0; - const minZoomLevel = getTimelineZoomMin({ - duration: timelineDuration, - containerWidth: tracksContainerRef.current?.clientWidth, - }); - - const savedViewState = editor.project.getTimelineViewState(); - - const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } = - useTimelineZoom({ - containerRef: timelineRef, - minZoom: minZoomLevel, - initialZoom: savedViewState?.zoomLevel, - initialScrollLeft: savedViewState?.scrollLeft, - initialPlayheadTime: savedViewState?.playheadTime, - tracksScrollRef, - rulerScrollRef, - }); - const { isResizing, handleResizeStart } = useTimelineResize({ - zoomLevel, - onSnapPointChange: handleSnapPointChange, - }); - - const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); - - const getTrackExpansionHeight = useCallback( - (trackIndex: number) => { - const track = tracks[trackIndex]; - if (!track) return 0; - return computeTrackExpansionHeight({ track, expandedElementIds }); - }, - [tracks, expandedElementIds], - ); - - // Stable refs so the wheel listener never goes stale - const setZoomLevelRef = useRef(setZoomLevel); - useEffect(() => { - setZoomLevelRef.current = setZoomLevel; - }, [setZoomLevel]); - - const saveScrollPositionRef = useRef(saveScrollPosition); - useEffect(() => { - saveScrollPositionRef.current = saveScrollPosition; - }, [saveScrollPosition]); - - const minZoomLevelRef = useRef(minZoomLevel); - useEffect(() => { - minZoomLevelRef.current = minZoomLevel; - }, [minZoomLevel]); - - // Pushes tracks scroll position to the two overflow:hidden followers - // (ruler and track labels). Called from the wheel handler (before paint, - // zero lag) and from onScroll on the tracks area (covers scrollbar drag). - const syncFollowers = useCallback(() => { - const tracks = tracksScrollRef.current; - if (!tracks) return; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = tracks.scrollLeft; - } - if (trackLabelsScrollRef.current) { - trackLabelsScrollRef.current.scrollTop = tracks.scrollTop; - } - }, []); - - // Single non-passive capture listener owns all wheel input. Prevents any - // native scroll or browser zoom from firing inside the timeline. - useEffect(() => { - const container = timelineRef.current; - if (!container) return; - - let pendingZoomDelta = 0; - let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null; - - const onWheel = (e: WheelEvent) => { - const isZoom = e.ctrlKey || e.metaKey; - - if (isZoom) { - e.preventDefault(); - const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; - pendingZoomDelta += normalizedDelta; - - if (zoomRafId === null) { - zoomRafId = requestAnimationFrame(() => { - const frameRawDelta = pendingZoomDelta; - const cappedDelta = - Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30); - const zoomFactor = Math.exp(-cappedDelta / 300); - setZoomLevelRef.current((prev) => prev * zoomFactor); - pendingZoomDelta = 0; - zoomRafId = null; - }); - } - return; - } - - const tracks = tracksScrollRef.current; - if (!tracks) return; - - const isHorizontal = - e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY); - - e.preventDefault(); - - if (isHorizontal) { - const raw = - Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; - const clamped = - Math.sign(raw) * - Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX); - tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped); - } else { - tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY); - } - - syncFollowers(); - saveScrollPositionRef.current(); - }; - - container.addEventListener("wheel", onWheel, { - passive: false, - capture: true, - }); - return () => { - container.removeEventListener("wheel", onWheel, { capture: true }); - if (zoomRafId !== null) cancelAnimationFrame(zoomRafId); - }; - }, [syncFollowers]); - - useInitialScrollBottom({ - tracksScrollRef, - trackLabelsScrollRef, - onAfterScroll: () => saveScrollPositionRef.current(), - isReady: tracks.length > 0, - }); - - const { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, - } = useElementInteraction({ - zoomLevel, - timelineRef, - tracksContainerRef, - tracksScrollRef, - snappingEnabled, - onSnapPointChange: handleSnapPointChange, - }); - - const { - dragState: bookmarkDragState, - handleBookmarkMouseDown, - lastMouseXRef: bookmarkLastMouseXRef, - } = useBookmarkDrag({ - zoomLevel, - scrollRef: tracksScrollRef, - snappingEnabled, - onSnapPointChange: handleSnapPointChange, - }); - - const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = - useTimelinePlayhead({ - zoomLevel, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ - containerRef: tracksContainerRef, - tracksScrollRef, - zoomLevel, - }); - - const { - selectionBox, - handleMouseDown: handleSelectionMouseDown, - isSelecting, - shouldIgnoreClick, - } = useBoxSelect({ - containerRef: tracksContainerRef, - selectedIds: selectedElements, - anchorId: null, - getIsAdditiveSelection: (event) => - event.shiftKey || event.ctrlKey || event.metaKey, - resolveIntersections: ({ startPos, currentPos }) => { - if (!tracksContainerRef.current) { - return []; - } - - return resolveTimelineElementIntersections({ - container: tracksContainerRef.current, - scrollContainer: tracksScrollRef.current, - tracks, - zoomLevel, - startPos, - currentPos, - }); - }, - onSelectionChange: ({ intersectedIds, isAdditive }) => { - if (isAdditive) { - mergeElementsIntoSelection({ elements: intersectedIds }); - } else { - setElementSelection({ elements: intersectedIds }); - } - }, - }); - - const containerWidth = - tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; - const contentWidth = timelineTimeToPixels({ - time: timelineDuration, - zoomLevel, - }); - const paddingPx = getTimelinePaddingPx({ - containerWidth, - zoomLevel, - minZoom: minZoomLevel, - }); - const dynamicTimelineWidth = Math.max( - contentWidth + paddingPx, - containerWidth, - ); - const tracksViewportWidth = - tracksScrollRef.current?.clientWidth ?? - tracksContainerRef.current?.clientWidth ?? - containerWidth; - const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; - - useEdgeAutoScroll({ - isActive: bookmarkDragState.isDragging, - getMouseClientX: () => bookmarkLastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: dynamicTimelineWidth, - }); - - const showSnapIndicator = - snappingEnabled && - currentSnapPoint !== null && - (dragState.isDragging || bookmarkDragState.isDragging || isResizing); - - const { - handleTracksMouseDown, - handleTracksClick, - handleRulerMouseDown, - handleRulerClick, - } = useTimelineSeek({ - playheadRef, - trackLabelsRef, - rulerScrollRef, - tracksScrollRef, - zoomLevel, - duration: timeline.getTotalDuration(), - isSelecting, - clearSelectedElements: clearElementSelection, - seek, - }); - - const timelineHeaderHeight = - (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + - TIMELINE_CONTENT_TOP_PADDING_PX || 0; - - return ( - <section - className={ - "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border" - } - {...dragProps} - aria-label="Timeline" - > - <TimelineToolbar - zoomLevel={zoomLevel} - minZoom={minZoomLevel} - setZoomLevel={({ zoom }) => setZoomLevel(zoom)} - /> - - <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}> - <TrackLabelsPanel - trackLabelsRef={trackLabelsRef} - trackLabelsScrollRef={trackLabelsScrollRef} - timelineHeaderHeight={timelineHeaderHeight} - hasHorizontalScrollbar={hasHorizontalScrollbar} - getTrackExpansionHeight={getTrackExpansionHeight} - /> - - <div - className="relative isolate flex flex-1 flex-col overflow-hidden" - ref={tracksContainerRef} - > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - <DragLine - dropTarget={dropTarget} - tracks={tracks} - isVisible={isDragOver && !dropTarget?.targetElement} - headerHeight={timelineHeaderHeight} - /> - <DragLine - dropTarget={dragDropTarget} - tracks={tracks} - isVisible={dragState.isDragging} - headerHeight={timelineHeaderHeight} - /> - - <div ref={rulerScrollRef} className="shrink-0 overflow-hidden"> - <div - ref={timelineHeaderRef} - className="flex flex-col" - style={{ width: `${dynamicTimelineWidth}px` }} - > - <TimelineRuler - zoomLevel={zoomLevel} - dynamicTimelineWidth={dynamicTimelineWidth} - rulerRef={rulerRef} - tracksScrollRef={rulerScrollRef} - handleWheel={handleWheel} - handleTimelineContentClick={handleRulerClick} - handleRulerTrackingMouseDown={handleRulerMouseDown} - handleRulerMouseDown={handlePlayheadRulerMouseDown} - /> - <TimelineBookmarksRow - zoomLevel={zoomLevel} - dynamicTimelineWidth={dynamicTimelineWidth} - dragState={bookmarkDragState} - onBookmarkMouseDown={handleBookmarkMouseDown} - handleWheel={handleWheel} - handleTimelineContentClick={handleRulerClick} - handleRulerTrackingMouseDown={handleRulerMouseDown} - handleRulerMouseDown={handlePlayheadRulerMouseDown} - /> - </div> - </div> - - <ScrollArea - className="flex-1" - ref={tracksScrollRef} - onScroll={() => { - syncFollowers(); - saveScrollPosition(); - }} - > - <div - className="flex min-h-full flex-col" - style={{ width: `${dynamicTimelineWidth}px` }} - > - {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} - <div - className="relative shrink-0" - style={{ - height: `${ - Math.max( - TRACKS_CONTAINER_HEIGHT.min, - Math.min( - TRACKS_CONTAINER_HEIGHT.max, - getTotalTracksHeight({ - tracks, - getExtraHeight: getTrackExpansionHeight, - }), - ), - ) + TIMELINE_CONTENT_TOP_PADDING_PX - }px`, - }} - onMouseDown={(event) => { - const isDirectTarget = event.target === event.currentTarget; - if (!isDirectTarget) return; - event.stopPropagation(); - handleTracksMouseDown(event); - handleSelectionMouseDown(event); - }} - onClick={(event) => { - const isDirectTarget = event.target === event.currentTarget; - if (!isDirectTarget) return; - event.stopPropagation(); - handleTracksClick(event); - }} - > - {tracks.length > 0 && ( - <TimelineTrackRows - mainTrackId={mainTrackId} - zoomLevel={zoomLevel} - dragState={dragState} - tracksScrollRef={tracksScrollRef} - lastMouseXRef={lastMouseXRef} - onResizeStart={handleResizeStart} - onElementMouseDown={handleElementMouseDown} - onElementClick={handleElementClick} - onTrackMouseDown={(event) => { - handleSelectionMouseDown(event); - handleTracksMouseDown(event); - }} - onTrackMouseUp={handleTracksClick} - shouldIgnoreClick={shouldIgnoreClick} - isDragOver={isDragOver} - dropTarget={dropTarget} - /> - )} - </div> - <TimelineGutter - onMouseDown={(event) => { - handleTracksMouseDown(event); - handleSelectionMouseDown(event); - }} - onClick={handleTracksClick} - /> - </div> - </ScrollArea> - - <TimelinePlayhead - zoomLevel={zoomLevel} - hasHorizontalScrollbar={hasHorizontalScrollbar} - rulerRef={rulerRef} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - timelineRef={timelineRef} - playheadRef={playheadRef} - isSnappingToPlayhead={ - showSnapIndicator && currentSnapPoint?.type === "playhead" - } - /> - </div> - <SnapIndicator - snapPoint={currentSnapPoint} - zoomLevel={zoomLevel} - timelineRef={timelineRef} - tracksScrollRef={tracksScrollRef} - isVisible={showSnapIndicator} - /> - </div> - </section> - ); -} - -function TrackLabelsPanel({ - trackLabelsRef, - trackLabelsScrollRef, - timelineHeaderHeight, - hasHorizontalScrollbar, - getTrackExpansionHeight, -}: { - trackLabelsRef: React.RefObject<HTMLDivElement | null>; - trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>; - timelineHeaderHeight: number; - hasHorizontalScrollbar: boolean; - getTrackExpansionHeight: (trackIndex: number) => number; -}) { - const editor = useEditor(); - const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); - const tracks = useMemo<TimelineTrack[]>( - () => - scene - ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] - : [], - [scene], - ); - const { selectedElements } = useElementSelection(); - const tracksWithSelection = useMemo( - () => new Set(selectedElements.map((el) => el.trackId)), - [selectedElements], - ); - - const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); - const trackExpandedRowsMap = useMemo( - () => - tracks.map((track) => - getTrackExpandedRows({ track, expandedElementIds }), - ), - [tracks, expandedElementIds], - ); - - return ( - <div - className="flex shrink-0 flex-col border-r" - style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }} - > - <div - className="shrink-0" - style={{ height: timelineHeaderHeight || 48 }} - /> - <div ref={trackLabelsRef} className="flex-1 overflow-hidden"> - <div ref={trackLabelsScrollRef} className="size-full overflow-hidden"> - {tracks.length > 0 && ( - <div - className="flex flex-col" - style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }} - > - {tracks.map((track, index) => { - const expandedRows = trackExpandedRowsMap[index]; - const baseHeight = getTrackHeight({ type: track.type }); - - return ( - <div - key={track.id} - className={cn( - "group flex flex-col", - tracksWithSelection.has(track.id) && - SELECTED_TRACK_ROW_CLASS, - )} - style={{ - height: `${baseHeight + getTrackExpansionHeight(index)}px`, - }} - > - <div - className="flex shrink-0 items-center justify-end gap-2 px-3" - style={{ height: `${baseHeight}px` }} - > - {canTrackHaveAudio(track) && ( - <TrackToggleIcon - isOff={track.muted} - icons={{ - on: VolumeHighIcon, - off: VolumeOffIcon, - }} - onClick={() => - editor.timeline.toggleTrackMute({ - trackId: track.id, - }) - } - /> - )} - {canTrackBeHidden(track) && ( - <TrackToggleIcon - isOff={track.hidden} - icons={{ - on: ViewIcon, - off: ViewOffSlashIcon, - }} - onClick={() => - editor.timeline.toggleTrackVisibility({ - trackId: track.id, - }) - } - /> - )} - <TrackIcon track={track} /> - </div> - {expandedRows.length > 0 && ( - <PropertyTree rows={expandedRows} /> - )} - </div> - ); - })} - </div> - )} - </div> - </div> - <div - className="bg-background shrink-0" - style={{ - height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0, - }} - /> - </div> - ); -} - -function TimelineTrackRows({ - mainTrackId, - zoomLevel, - dragState, - tracksScrollRef, - lastMouseXRef, - onResizeStart, - onElementMouseDown, - onElementClick, - onTrackMouseDown, - onTrackMouseUp, - shouldIgnoreClick, - isDragOver, - dropTarget, -}: { - mainTrackId: string | null; - zoomLevel: number; - dragState: ElementDragState; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; - lastMouseXRef: React.RefObject<number>; - onResizeStart: React.ComponentProps< - typeof TimelineTrackContent - >["onResizeStart"]; - onElementMouseDown: React.ComponentProps< - typeof TimelineTrackContent - >["onElementMouseDown"]; - onElementClick: React.ComponentProps< - typeof TimelineTrackContent - >["onElementClick"]; - onTrackMouseDown: (event: React.MouseEvent) => void; - onTrackMouseUp: (event: React.MouseEvent) => void; - shouldIgnoreClick: () => boolean; - isDragOver: boolean; - dropTarget: DropTarget | null; -}) { - const timeline = useEditor((e) => e.timeline); - const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); - const tracks = useMemo<TimelineTrack[]>( - () => - scene - ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] - : [], - [scene], - ); - const { selectedElements } = useElementSelection(); - const tracksWithSelection = useMemo( - () => new Set(selectedElements.map((el) => el.trackId)), - [selectedElements], - ); - - const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); - - const getTrackExpansionHeight = useCallback( - (trackIndex: number) => { - const track = tracks[trackIndex]; - if (!track) return 0; - return computeTrackExpansionHeight({ track, expandedElementIds }); - }, - [tracks, expandedElementIds], - ); - - const sortedTracks = useMemo(() => { - const draggingElementIds = new Set(dragState.dragElementIds); - return [...tracks] - .map((track, index) => ({ track, index })) - .sort((a, b) => { - const aHasDragged = a.track.elements.some((element) => - draggingElementIds.has(element.id), - ); - const bHasDragged = b.track.elements.some((element) => - draggingElementIds.has(element.id), - ); - if (aHasDragged) return 1; - if (bHasDragged) return -1; - return 0; - }); - }, [tracks, dragState.dragElementIds]); - - return ( - <> - {sortedTracks.map(({ track, index }) => ( - <ContextMenu key={track.id}> - <ContextMenuTrigger asChild> - <div - className={cn( - "absolute right-0 left-0 transition-colors", - tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS, - )} - style={{ - top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`, - height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`, - }} - > - <TimelineTrackContent - track={track} - zoomLevel={zoomLevel} - dragState={dragState} - rulerScrollRef={tracksScrollRef} - tracksScrollRef={tracksScrollRef} - lastMouseXRef={lastMouseXRef} - onResizeStart={onResizeStart} - onElementMouseDown={onElementMouseDown} - onElementClick={onElementClick} - onTrackMouseDown={onTrackMouseDown} - onTrackMouseUp={onTrackMouseUp} - shouldIgnoreClick={shouldIgnoreClick} - targetElementId={ - isDragOver - ? (dropTarget?.targetElement?.elementId ?? null) - : null - } - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent className="w-40"> - <ContextMenuItem - icon={<HugeiconsIcon icon={TaskAdd02Icon} />} - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - </ContextMenuItem> - <ContextMenuItem - icon={<HugeiconsIcon icon={VolumeHighIcon} />} - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - timeline.toggleTrackMute({ trackId: track.id }); - }} - > - {canTrackHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - </ContextMenuItem> - <ContextMenuItem - icon={<HugeiconsIcon icon={ViewIcon} />} - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - timeline.toggleTrackVisibility({ trackId: track.id }); - }} - > - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - </ContextMenuItem> - {track.id !== mainTrackId && ( - <ContextMenuItem - icon={<HugeiconsIcon icon={Delete02Icon} />} - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - timeline.removeTrack({ trackId: track.id }); - }} - variant="destructive" - > - Delete track - </ContextMenuItem> - )} - </ContextMenuContent> - </ContextMenu> - ))} - </> - ); -} - -function TimelineGutter({ - onMouseDown, - onClick, -}: { - onMouseDown: (event: React.MouseEvent) => void; - onClick: (event: React.MouseEvent) => void; -}) { - // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system - // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system - return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />; -} - -function TrackIcon({ track }: { track: TimelineTrack }) { - return <>{TRACK_ICONS[track.type]}</>; -} - -function TrackToggleIcon({ - isOff, - icons, - onClick, -}: { - isOff: boolean; - icons: { - on: IconSvgElement; - off: IconSvgElement; - }; - onClick: () => void; -}) { - return ( - <> - {isOff ? ( - <HugeiconsIcon - icon={icons.off} - className="text-destructive size-4 cursor-pointer" - onClick={onClick} - /> - ) : ( - <HugeiconsIcon - icon={icons.on} - className="text-muted-foreground size-4 cursor-pointer" - onClick={onClick} - /> - )} - </> - ); -} - -function PropertyTree({ rows }: { rows: ExpandedRow[] }) { - return ( - <div className="flex flex-col overflow-hidden"> - {rows.map((row) => ( - <div - key={row.propertyPath} - className={cn("flex shrink-0 items-center px-3 bg-muted/50")} - style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} - > - <span className="text-muted-foreground truncate text-xs leading-none"> - {getPropertyLabel(row.propertyPath)} - </span> - </div> - ))} - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/interaction.ts b/apps/web/src/components/editor/panels/timeline/interaction.ts deleted file mode 100644 index fe0210a4..00000000 --- a/apps/web/src/components/editor/panels/timeline/interaction.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const TIMELINE_DRAG_THRESHOLD_PX = 5; -export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40; -export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7; -export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15; diff --git a/apps/web/src/components/editor/panels/timeline/layers.ts b/apps/web/src/components/editor/panels/timeline/layers.ts deleted file mode 100644 index d59db412..00000000 --- a/apps/web/src/components/editor/panels/timeline/layers.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const TIMELINE_LAYERS = { - trackContent: 10, - dragLine: 20, - playhead: 30, - snapIndicator: 40, -} as const; diff --git a/apps/web/src/components/editor/panels/timeline/layout.ts b/apps/web/src/components/editor/panels/timeline/layout.ts deleted file mode 100644 index c30b7877..00000000 --- a/apps/web/src/components/editor/panels/timeline/layout.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { TrackType } from "@/lib/timeline"; - -export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = { - video: 65, - text: 25, - audio: 50, - graphic: 25, - effect: 25, -} as const; - -export const KEYFRAME_LANE_HEIGHT_PX = 20; -export const KEYFRAME_DIAMOND_SIZE_PX = 14; -export const EXPANDED_GROUP_HEADER_HEIGHT_PX = 18; - -export const TIMELINE_TRACK_GAP_PX = 6; -export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112; -export const TIMELINE_RULER_HEIGHT_PX = 22; -export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16; -export const TIMELINE_SCROLLBAR_SIZE_PX = 12; -export const TIMELINE_CONTENT_TOP_PADDING_PX = 2; diff --git a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts deleted file mode 100644 index 551393e9..00000000 --- a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { TimelineTrack } from "@/lib/timeline"; -import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils"; -import { - TIMELINE_CONTENT_TOP_PADDING_PX, -} from "./layout"; -import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout"; - -type TimelineElementRef = { trackId: string; elementId: string }; - -interface SelectionRectangle { - left: number; - top: number; - right: number; - bottom: number; -} - -function getNormalizedRectangle({ - startPos, - endPos, -}: { - startPos: { x: number; y: number }; - endPos: { x: number; y: number }; -}): SelectionRectangle { - return { - left: Math.min(startPos.x, endPos.x), - top: Math.min(startPos.y, endPos.y), - right: Math.max(startPos.x, endPos.x), - bottom: Math.max(startPos.y, endPos.y), - }; -} - -function getSelectionRectangleInContent({ - container, - scrollContainer, - startPos, - endPos, -}: { - container: HTMLElement; - scrollContainer: HTMLDivElement | null; - startPos: { x: number; y: number }; - endPos: { x: number; y: number }; -}): SelectionRectangle { - const containerRect = container.getBoundingClientRect(); - const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - - const adjustedStart = { - x: startPos.x - containerRect.left + scrollLeft, - y: startPos.y - scrollRect.top + scrollTop, - }; - const adjustedEnd = { - x: endPos.x - containerRect.left + scrollLeft, - y: endPos.y - scrollRect.top + scrollTop, - }; - - return getNormalizedRectangle({ - startPos: adjustedStart, - endPos: adjustedEnd, - }); -} - -function isRectangleIntersecting({ - elementRectangle, - selectionRectangle, -}: { - elementRectangle: SelectionRectangle; - selectionRectangle: SelectionRectangle; -}): boolean { - return !( - elementRectangle.right < selectionRectangle.left || - elementRectangle.left > selectionRectangle.right || - elementRectangle.bottom < selectionRectangle.top || - elementRectangle.top > selectionRectangle.bottom - ); -} - -export function resolveTimelineElementIntersections({ - container, - scrollContainer, - tracks, - zoomLevel, - startPos, - currentPos, -}: { - container: HTMLElement; - scrollContainer: HTMLDivElement | null; - tracks: TimelineTrack[]; - zoomLevel: number; - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; -}): TimelineElementRef[] { - const selectionRectangle = getSelectionRectangleInContent({ - container, - scrollContainer, - startPos, - endPos: currentPos, - }); - const selectedElements: TimelineElementRef[] = []; - - for (const [trackIndex, track] of tracks.entries()) { - const trackTop = getCumulativeHeightBefore({ - tracks, - trackIndex, - }); - const trackHeight = getTrackHeight({ type: track.type }); - const elementTop = TIMELINE_CONTENT_TOP_PADDING_PX + trackTop; - const elementBottom = elementTop + trackHeight; - - for (const element of track.elements) { - const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel }); - const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel }); - const elementRectangle = { - left: elementLeft, - top: elementTop, - right: elementRight, - bottom: elementBottom, - }; - - if ( - isRectangleIntersecting({ - elementRectangle, - selectionRectangle, - }) - ) { - selectedElements.push({ - trackId: track.id, - elementId: element.id, - }); - } - } - } - - return selectedElements; -} diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx deleted file mode 100644 index a86e2a5e..00000000 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client";
-
-import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import {
- getCenteredLineLeft,
- TIMELINE_INDICATOR_LINE_WIDTH_PX,
-} from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "./layers";
-interface SnapIndicatorProps {
- snapPoint: SnapPoint | null;
- zoomLevel: number;
- isVisible: boolean;
- timelineRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
-}
-
-export function SnapIndicator({
- snapPoint,
- zoomLevel,
- isVisible,
- timelineRef,
- tracksScrollRef,
-}: SnapIndicatorProps) {
- const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
- snapPoint,
- zoomLevel,
- timelineRef,
- tracksScrollRef,
- });
-
- if (!isVisible || !snapPoint) {
- return null;
- }
-
- return (
- <div
- className="pointer-events-none absolute"
- style={{
- left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
- top: topPosition,
- height: `${height}px`,
- width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
- zIndex: TIMELINE_LAYERS.snapIndicator,
- }}
- >
- <div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/theme.ts b/apps/web/src/components/editor/panels/timeline/theme.ts deleted file mode 100644 index 0afe3eb4..00000000 --- a/apps/web/src/components/editor/panels/timeline/theme.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { TrackType } from "@/lib/timeline"; - -export const TIMELINE_AUDIO_WAVEFORM_COLOR = "rgba(255, 255, 255, 0.7)"; - -export const TIMELINE_TRACK_THEME: Record< - TrackType, - { - elementClassName: string; - waveformColor?: string; - } -> = { - video: { elementClassName: "transparent" }, - text: { elementClassName: "bg-[#5DBAA0]" }, - audio: { - elementClassName: "bg-[#8F5DBA]", - waveformColor: TIMELINE_AUDIO_WAVEFORM_COLOR, - }, - graphic: { elementClassName: "bg-[#BA5D7A]" }, - effect: { elementClassName: "bg-[#5d93ba]" }, -} as const; - -export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50"; -export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff"; - -export function getTimelineElementClassName({ - type, -}: { - type: TrackType; -}): string { - return TIMELINE_TRACK_THEME[type].elementClassName.trim(); -} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx deleted file mode 100644 index 75a2dfa8..00000000 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ /dev/null @@ -1,1305 +0,0 @@ -"use client"; - -import { createContext, useContext } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { useAssetsPanelStore } from "@/stores/assets-panel-store"; -import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform"; -import { AudioVolumeLine } from "./audio-volume-line"; -import { useElementPreview } from "@/hooks/use-element-preview"; -import { - useKeyframeDrag, - type KeyframeDragState, -} from "@/hooks/timeline/element/use-keyframe-drag"; -import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; -import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select"; -import { SelectionBox } from "@/lib/selection/selection-box"; -import { getElementKeyframes } from "@/lib/animation"; -import { - canElementHaveAudio, - canElementBeHidden, - hasElementEffects, - hasMediaId, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/lib/timeline"; -import { getTrackHeight } from "./track-layout"; -import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import type { - TimelineElement as TimelineElementType, - TimelineTrack, - ElementDragState, - VideoElement, - ImageElement, - AudioElement, -} from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; -import { mediaSupportsAudio } from "@/lib/media/media-utils"; -import { - canToggleSourceAudio, - getSourceAudioActionLabel, - isSourceAudioSeparated, -} from "@/lib/timeline/audio-separation"; -import { buildWaveformGainSamples } from "@/lib/timeline/audio-state"; -import { getTimelinePixelsPerSecond } from "@/lib/timeline"; -import { buildWaveformSourceKey } from "@/lib/media/waveform-summary"; -import { TICKS_PER_SECOND } from "@/lib/wasm/ticks"; -import { - getActionDefinition, - type TAction, - type TActionWithOptionalArgs, - invokeAction, -} from "@/lib/actions"; -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { resolveStickerId } from "@/lib/stickers"; -import { buildGraphicPreviewUrl } from "@/lib/graphics"; -import Image from "next/image"; -import { - ScissorIcon, - Delete02Icon, - Copy01Icon, - ViewIcon, - ViewOffSlashIcon, - VolumeHighIcon, - VolumeOffIcon, - VolumeMute02Icon, - Search01Icon, - Exchange01Icon, - KeyframeIcon, - MagicWand05Icon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { uppercase } from "@/utils/string"; -import { useMemo, type ComponentProps, type ReactNode } from "react"; -import type { - SelectedKeyframeRef, - ElementKeyframe, -} from "@/lib/animation/types"; -import { cn } from "@/utils/ui"; -import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; -import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { KEYFRAME_LANE_HEIGHT_PX } from "./layout"; -import { - getExpandedRows, - getExpansionHeight, - type ExpandedRow, -} from "./expanded-layout"; - -const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; -const ELEMENT_RING_WIDTH_PX = 1.5; - -const PixelsPerSecondContext = createContext<number | null>(null); -const THUMBNAIL_ASPECT_RATIO = 16 / 9; - -interface KeyframeIndicator { - time: number; - offsetPx: number; - keyframes: SelectedKeyframeRef[]; -} - -export function buildKeyframeIndicator({ - keyframe, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, -}: { - keyframe: ElementKeyframe; - trackId: string; - elementId: string; - displayedStartTime: number; - zoomLevel: number; - elementLeft: number; -}): { - time: number; - offsetPx: number; - keyframeRef: SelectedKeyframeRef; -} { - const keyframeRef = { - trackId, - elementId, - propertyPath: keyframe.propertyPath, - keyframeId: keyframe.id, - }; - const keyframeLeft = timelineTimeToSnappedPixels({ - time: displayedStartTime + keyframe.time, - zoomLevel, - }); - return { - time: keyframe.time, - offsetPx: keyframeLeft - elementLeft, - keyframeRef, - }; -} - -export function getKeyframeIndicators({ - keyframes, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, - elementWidth, -}: { - keyframes: ElementKeyframe[]; - trackId: string; - elementId: string; - displayedStartTime: number; - zoomLevel: number; - elementLeft: number; - elementWidth: number; -}): KeyframeIndicator[] { - if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { - return []; - } - - const keyframesByTime = new Map<number, KeyframeIndicator>(); - for (const keyframe of keyframes) { - const indicator = buildKeyframeIndicator({ - keyframe, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, - }); - const existingIndicator = keyframesByTime.get(indicator.time); - if (!existingIndicator) { - keyframesByTime.set(indicator.time, { - time: indicator.time, - offsetPx: indicator.offsetPx, - keyframes: [indicator.keyframeRef], - }); - continue; - } - - existingIndicator.keyframes.push(indicator.keyframeRef); - } - - return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); -} - -export function getDisplayShortcut({ action }: { action: TAction }) { - const { defaultShortcuts } = getActionDefinition({ action }); - if (!defaultShortcuts?.length) { - return ""; - } - - return uppercase({ - string: defaultShortcuts[0].replace("+", " "), - }); -} - -interface TimelineElementProps { - element: TimelineElementType; - track: TimelineTrack; - zoomLevel: number; - isSelected: boolean; - onResizeStart: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - side: "left" | "right"; - }) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - dragState: ElementDragState; - isDropTarget?: boolean; -} - -export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onResizeStart, - onElementMouseDown, - onElementClick, - dragState, - isDropTarget = false, -}: TimelineElementProps) { - const mediaAssets = useEditor((e) => e.media.getAssets()); - const { selectedElements } = useElementSelection(); - const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); - const { renderElement } = useElementPreview({ - trackId: track.id, - elementId: element.id, - fallback: element, - }); - - let mediaAsset: MediaAsset | null = null; - - if (hasMediaId(element)) { - mediaAsset = - mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; - } - - const hasAudio = mediaSupportsAudio({ media: mediaAsset }); - - const isCurrentElementSelected = selectedElements.some( - (selected) => - selected.elementId === element.id && selected.trackId === track.id, - ); - - const isBeingDragged = dragState.dragElementIds.includes(element.id); - const dragOffsetY = - isBeingDragged && dragState.isDragging - ? dragState.currentMouseY - dragState.startMouseY - : 0; - const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; - const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime + dragTimeOffset - : renderElement.startTime; - const displayedStartTime = elementStartTime; - const displayedDuration = renderElement.duration; - const elementWidth = timelineTimeToPixels({ - time: displayedDuration, - zoomLevel, - }); - const timelinePixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel }); - const elementLeft = timelineTimeToSnappedPixels({ - time: displayedStartTime, - zoomLevel, - }); - const keyframeIndicators = isSelected - ? getKeyframeIndicators({ - keyframes: getElementKeyframes({ animations: element.animations }), - trackId: track.id, - elementId: element.id, - displayedStartTime, - zoomLevel, - elementLeft, - elementWidth, - }) - : []; - - const { - keyframeDragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, - } = useKeyframeDrag({ zoomLevel, element, displayedStartTime }); - - const elementKeyframes = getElementKeyframes({ - animations: element.animations, - }); - - const isExpanded = useTimelineStore((s) => - s.expandedElementIds.has(element.id), - ); - const toggleElementExpanded = useTimelineStore( - (s) => s.toggleElementExpanded, - ); - const expandedRows = useMemo( - () => - isExpanded ? getExpandedRows({ animations: element.animations }) : [], - [isExpanded, element.animations], - ); - - const { - containerRef: expandedLanesRef, - selectionBox: keyframeSelectionBox, - isBoxSelecting: isKeyframeBoxSelecting, - handleExpandedAreaMouseDown, - handleExpandedAreaClick, - } = useKeyframeBoxSelect({ - trackId: track.id, - elementId: element.id, - rows: expandedRows, - keyframes: elementKeyframes, - displayedStartTime, - zoomLevel, - elementLeft, - }); - - const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { - event.stopPropagation(); - if (hasMediaId(element)) { - requestRevealMedia(element.mediaId); - } - }; - - const isMuted = canElementHaveAudio(element) && element.muted === true; - const canToggleCurrentSourceAudio = - selectedElements.length === 1 && - isCurrentElementSelected && - canToggleSourceAudio(element, mediaAsset); - const sourceAudioLabel = - element.type === "video" - ? getSourceAudioActionLabel({ element }) - : "Extract audio"; - const isElementSourceAudioSeparated = - element.type === "video" && isSourceAudioSeparated({ element }); - const hasKeyframes = elementKeyframes.length > 0; - const expansionHeight = getExpansionHeight({ rows: expandedRows }); - const baseTrackHeight = getTrackHeight({ type: track.type }); - - const expandedContent = - isExpanded && expandedRows.length > 0 ? ( - <ExpandedKeyframeLanes - rows={expandedRows} - keyframes={elementKeyframes} - trackId={track.id} - elementId={element.id} - displayedStartTime={displayedStartTime} - zoomLevel={zoomLevel} - elementLeft={elementLeft} - keyframeDragState={keyframeDragState} - onKeyframeMouseDown={handleKeyframeMouseDown} - onKeyframeClick={handleKeyframeClick} - getVisualOffsetPx={getVisualOffsetPx} - containerRef={expandedLanesRef} - onLaneMouseDown={handleExpandedAreaMouseDown} - onLaneClick={handleExpandedAreaClick} - selectionBox={keyframeSelectionBox} - isBoxSelecting={isKeyframeBoxSelecting} - /> - ) : null; - - return ( - <PixelsPerSecondContext.Provider value={timelinePixelsPerSecond}> - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className="absolute top-0 select-none" - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - height: - expandedRows.length > 0 - ? `${baseTrackHeight + expansionHeight}px` - : "100%", - transform: - isBeingDragged && dragState.isDragging - ? `translate3d(0, ${dragOffsetY}px, 0)` - : undefined, - }} - > - <ElementInner - element={element} - displayElement={renderElement} - track={track} - isSelected={isSelected} - isExpanded={expandedRows.length > 0} - baseTrackHeight={baseTrackHeight} - expandedContent={expandedContent} - onElementClick={onElementClick} - onElementMouseDown={onElementMouseDown} - onResizeStart={onResizeStart} - isDropTarget={isDropTarget} - /> - {isSelected && ( - <div - className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden" - style={{ height: `${baseTrackHeight}px` }} - > - <KeyframeIndicators - indicators={keyframeIndicators} - dragState={keyframeDragState} - displayedStartTime={displayedStartTime} - elementLeft={elementLeft} - onKeyframeMouseDown={handleKeyframeMouseDown} - onKeyframeClick={handleKeyframeClick} - getVisualOffsetPx={getVisualOffsetPx} - /> - </div> - )} - </div> - </ContextMenuTrigger> - <ContextMenuContent className="w-64"> - <ActionMenuItem - action="split" - icon={<HugeiconsIcon icon={ScissorIcon} />} - > - Split - </ActionMenuItem> - <CopyMenuItem /> - {selectedElements.length === 1 && ( - <ActionMenuItem - action="duplicate-selected" - icon={<HugeiconsIcon icon={Copy01Icon} />} - > - Duplicate - </ActionMenuItem> - )} - {canElementHaveAudio(element) && hasAudio && ( - <MuteMenuItem - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - isMuted={isMuted} - /> - )} - {canToggleCurrentSourceAudio && ( - <ContextMenuItem - icon={ - <HugeiconsIcon - icon={ - isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon - } - /> - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - invokeAction("toggle-source-audio"); - }} - > - {sourceAudioLabel} - </ContextMenuItem> - )} - {canElementBeHidden(element) && ( - <VisibilityMenuItem - element={element} - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - /> - )} - {hasKeyframes && ( - <ContextMenuItem - icon={<HugeiconsIcon icon={KeyframeIcon} />} - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - toggleElementExpanded(element.id); - }} - > - {isExpanded ? "Collapse keyframes" : "Expand keyframes"} - </ContextMenuItem> - )} - {selectedElements.length === 1 && hasMediaId(element) && ( - <> - <ContextMenuItem - icon={<HugeiconsIcon icon={Search01Icon} />} - onClick={(event: React.MouseEvent) => - handleRevealInMedia({ event }) - } - > - Reveal media - </ContextMenuItem> - <ContextMenuItem - icon={<HugeiconsIcon icon={Exchange01Icon} />} - disabled - > - Replace media - </ContextMenuItem> - </> - )} - <ContextMenuSeparator /> - <DeleteMenuItem - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - elementType={element.type} - selectedCount={selectedElements.length} - /> - </ContextMenuContent> - </ContextMenu> - </PixelsPerSecondContext.Provider> - ); -} - -function ElementInner({ - element, - displayElement, - track, - isSelected, - isExpanded, - baseTrackHeight, - expandedContent, - onElementClick, - onElementMouseDown, - onResizeStart, - isDropTarget = false, -}: { - element: TimelineElementType; - displayElement?: TimelineElementType; - track: TimelineTrack; - isSelected: boolean; - isExpanded: boolean; - baseTrackHeight: number; - expandedContent: React.ReactNode; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onResizeStart: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - side: "left" | "right"; - }) => void; - isDropTarget?: boolean; -}) { - const visibleElement = displayElement ?? element; - const isReducedOpacity = - (canElementBeHidden(visibleElement) && visibleElement.hidden) || - isDropTarget; - return ( - <div - className="absolute top-0 bottom-0" - style={{ - left: `${ELEMENT_RING_WIDTH_PX}px`, - right: `${ELEMENT_RING_WIDTH_PX}px`, - }} - > - <div - className="absolute inset-0 rounded-sm" - style={ - isSelected - ? { - boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`, - } - : undefined - } - > - <div - className={cn( - "absolute inset-0 overflow-hidden rounded-sm", - isExpanded && "bg-background", - )} - > - <button - type="button" - tabIndex={-1} - className="absolute inset-0 size-full flex flex-col" - onClick={(event) => onElementClick(event, element)} - onMouseDown={(event) => onElementMouseDown(event, element)} - > - <div - className={cn( - "flex shrink-0 items-center overflow-hidden", - getTimelineElementClassName({ - type: getTrackTypeForElementType({ - elementType: element.type, - }), - }), - isReducedOpacity && "opacity-50", - )} - style={{ height: `${baseTrackHeight}px` }} - > - <div className="flex flex-1 min-h-0 h-full items-center overflow-hidden"> - <ElementContent element={visibleElement} track={track} /> - </div> - </div> - {expandedContent} - </button> - </div> - </div> - - {isSelected && ( - <> - <ResizeHandle - side="left" - element={element} - track={track} - onResizeStart={onResizeStart} - /> - <ResizeHandle - side="right" - element={element} - track={track} - onResizeStart={onResizeStart} - /> - </> - )} - </div> - ); -} - -function ResizeHandle({ - side, - element, - track, - onResizeStart, -}: { - side: "left" | "right"; - element: TimelineElementType; - track: TimelineTrack; - onResizeStart: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - side: "left" | "right"; - }) => void; -}) { - const isLeft = side === "left"; - return ( - <button - type="button" - className={cn( - "absolute top-0 bottom-0 w-2", - isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize", - )} - onMouseDown={(event) => onResizeStart({ event, element, track, side })} - onClick={(event) => event.stopPropagation()} - aria-label={`${isLeft ? "Left" : "Right"} resize handle`} - ></button> - ); -} - -function KeyframeIndicators({ - indicators, - dragState, - displayedStartTime, - elementLeft, - onKeyframeMouseDown, - onKeyframeClick, - getVisualOffsetPx, -}: { - indicators: KeyframeIndicator[]; - dragState: KeyframeDragState; - displayedStartTime: number; - elementLeft: number; - onKeyframeMouseDown: (params: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => void; - onKeyframeClick: (params: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; - }) => void; - getVisualOffsetPx: (params: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }) => number; -}) { - const { isKeyframeSelected } = useKeyframeSelection(); - const orderedKeyframes = indicators.flatMap( - (indicator) => indicator.keyframes, - ); - - return indicators.map((indicator) => { - const isIndicatorSelected = indicator.keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - const isBeingDragged = indicator.keyframes.some((keyframe) => - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - const visualOffsetPx = getVisualOffsetPx({ - indicatorTime: indicator.time, - indicatorOffsetPx: indicator.offsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }); - - return ( - <button - key={indicator.time} - type="button" - className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab mr-0.5" - style={{ left: visualOffsetPx }} - onMouseDown={(event) => - onKeyframeMouseDown({ event, keyframes: indicator.keyframes }) - } - onClick={(event) => - onKeyframeClick({ - event, - keyframes: indicator.keyframes, - orderedKeyframes, - indicatorTime: indicator.time, - }) - } - aria-label="Select keyframe" - > - <HugeiconsIcon - icon={KeyframeIcon} - className={cn( - "size-3.5 text-black", - isIndicatorSelected ? "fill-primary" : "fill-white", - )} - strokeWidth={1.5} - /> - </button> - ); - }); -} - -function ExpandedKeyframeLanes({ - rows, - keyframes, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, - keyframeDragState, - onKeyframeMouseDown, - onKeyframeClick, - getVisualOffsetPx, - containerRef, - onLaneMouseDown, - onLaneClick, - selectionBox, - isBoxSelecting, -}: { - rows: ExpandedRow[]; - keyframes: ElementKeyframe[]; - trackId: string; - elementId: string; - displayedStartTime: number; - zoomLevel: number; - elementLeft: number; - keyframeDragState: KeyframeDragState; - onKeyframeMouseDown: (params: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => void; - containerRef: React.RefObject<HTMLDivElement | null>; - onLaneMouseDown: (event: React.MouseEvent) => void; - onLaneClick: (event: React.MouseEvent) => void; - selectionBox: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; - } | null; - isBoxSelecting: boolean; - onKeyframeClick: (params: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; - }) => void; - getVisualOffsetPx: (params: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }) => number; -}) { - const { isKeyframeSelected } = useKeyframeSelection(); - - const orderedKeyframes = useMemo( - () => - [...keyframes] - .sort( - (a, b) => - a.time - b.time || a.propertyPath.localeCompare(b.propertyPath), - ) - .map((kf) => ({ - trackId, - elementId, - propertyPath: kf.propertyPath, - keyframeId: kf.id, - })), - [keyframes, trackId, elementId], - ); - - return ( - // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface - // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface - <div - ref={containerRef} - className="relative flex flex-col" - onMouseDown={onLaneMouseDown} - onClick={onLaneClick} - > - {rows.map((row) => { - const laneKeyframes = keyframes.filter( - (kf) => kf.propertyPath === row.propertyPath, - ); - return ( - <div - key={row.propertyPath} - className={cn("relative flex items-center bg-muted/50")} - style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} - > - {laneKeyframes.map((kf) => { - const keyframeRef: SelectedKeyframeRef = { - trackId, - elementId, - propertyPath: row.propertyPath, - keyframeId: kf.id, - }; - const isBeingDragged = keyframeDragState.draggingKeyframeIds.has( - kf.id, - ); - const kfLeft = timelineTimeToSnappedPixels({ - time: displayedStartTime + kf.time, - zoomLevel, - }); - const offsetPx = kfLeft - elementLeft; - const visualOffset = getVisualOffsetPx({ - indicatorTime: kf.time, - indicatorOffsetPx: offsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }); - const isSelected = isKeyframeSelected({ - keyframe: keyframeRef, - }); - - return ( - <button - key={kf.id} - type="button" - className={cn( - "pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab", - isBoxSelecting && "pointer-events-none", - )} - style={{ left: visualOffset }} - onMouseDown={(event) => { - event.stopPropagation(); - onKeyframeMouseDown({ - event, - keyframes: [keyframeRef], - }); - }} - onClick={(event) => { - event.stopPropagation(); - onKeyframeClick({ - event, - keyframes: [keyframeRef], - orderedKeyframes, - indicatorTime: kf.time, - }); - }} - aria-label="Select keyframe" - > - <HugeiconsIcon - icon={KeyframeIcon} - className={cn( - "size-3.5 text-black mr-1", - isSelected ? "fill-primary" : "fill-white", - )} - strokeWidth={1.5} - /> - </button> - ); - })} - </div> - ); - })} - {selectionBox && ( - <SelectionBox - startPos={selectionBox.startPos} - currentPos={selectionBox.currentPos} - containerRef={containerRef} - isActive={selectionBox.isActive} - /> - )} - </div> - ); -} - -interface ElementContentProps { - element: TimelineElementType; - track: TimelineTrack; -} - -function TextElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "text" }>; -}) { - return ( - <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white">{element.content}</span> - </div> - ); -} - -function EffectElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "effect" }>; -}) { - return ( - <div className="flex size-full items-center justify-start gap-1 pl-2"> - <HugeiconsIcon - icon={MagicWand05Icon} - className="size-4 shrink-0 text-white" - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); -} - -function StickerElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "sticker" }>; -}) { - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={resolveStickerId({ - stickerId: element.stickerId, - options: { width: 20, height: 20 }, - })} - alt={element.name} - className="size-4 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); -} - -function GraphicElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "graphic" }>; -}) { - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={buildGraphicPreviewUrl({ - definitionId: element.definitionId, - params: element.params, - size: 20, - })} - alt={element.name} - className="size-4 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); -} - -function AudioElementContent({ - element, - trackId, -}: { - element: AudioElement; - trackId: string; -}) { - const pixelsPerSecond = useContext(PixelsPerSecondContext); - if (pixelsPerSecond === null) { - throw new Error( - "AudioElementContent must be rendered inside PixelsPerSecondContext.Provider", - ); - } - const mediaAssets = useEditor((e) => e.media.getAssets()); - const mediaAsset = - element.sourceType === "upload" - ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) - : null; - - const audioBuffer = - element.sourceType === "library" ? element.buffer : undefined; - const audioUrl = - element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; - const sourceFile = - element.sourceType === "upload" ? mediaAsset?.file : undefined; - const sourceKey = - element.sourceType === "upload" - ? buildWaveformSourceKey({ kind: "media", id: element.mediaId }) - : buildWaveformSourceKey({ kind: "library", id: element.sourceUrl }); - const mediaLabel = mediaAsset?.name ?? element.name; - const gainSamples = useMemo( - () => - buildWaveformGainSamples({ - element, - count: WAVEFORM_GAIN_SAMPLE_COUNT, - }), - [element], - ); - if (audioBuffer || audioUrl || sourceFile) { - return ( - <div className="group/audio relative size-full"> - <MediaElementHeader name={mediaLabel} hasFade={false} /> - <div className="absolute inset-x-0 top-5 bottom-0 overflow-hidden"> - <AudioWaveform - sourceKey={sourceKey} - sourceFile={sourceFile} - audioBuffer={audioBuffer} - audioUrl={audioUrl} - gainSamples={gainSamples} - pixelsPerSecond={pixelsPerSecond} - clipDurationSec={element.duration / TICKS_PER_SECOND} - retime={element.retime} - sourceStartSec={element.trimStart / TICKS_PER_SECOND} - color={TIMELINE_TRACK_THEME.audio.waveformColor} - /> - <AudioVolumeLine element={element} trackId={trackId} /> - </div> - </div> - ); - } - - return ( - <div className="group/audio relative size-full"> - <div className="flex size-full items-center pl-2"> - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> - </div> - <AudioVolumeLine element={element} trackId={trackId} /> - </div> - ); -} - -function EffectsButton({ - element, - track, -}: { - element: VideoElement | ImageElement; - track: TimelineTrack; -}) { - const editor = useEditor(); - const setActiveTab = usePropertiesStore((s) => s.setActiveTab); - - const handleClick = (event: React.MouseEvent) => { - event.stopPropagation(); - editor.selection.setSelectedElements({ - elements: [{ trackId: track.id, elementId: element.id }], - }); - setActiveTab(element.type, "effects"); - }; - - return ( - <button - type="button" - className="flex shrink-0 justify-center text-white cursor-pointer" - onMouseDown={(event) => event.stopPropagation()} - onClick={handleClick} - > - <HugeiconsIcon icon={MagicWand05Icon} size={12} /> - </button> - ); -} - -function TiledMediaContent({ - element, - track, -}: { - element: VideoElement | ImageElement; - track: TimelineTrack; -}) { - const mediaAssets = useEditor((e) => e.media.getAssets()); - - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - const imageUrl = - element.type === "video" - ? mediaAsset?.thumbnailUrl - : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); - - if (!imageUrl) { - return ( - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> - ); - } - - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; - - return ( - <> - <div - className="absolute inset-0" - style={{ - backgroundColor: "var(--muted)", - backgroundImage: `url(${imageUrl})`, - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - /> - <MediaElementHeader - name={mediaAsset?.name} - leading={ - hasElementEffects({ element }) ? ( - <EffectsButton element={element} track={track} /> - ) : null - } - hasFade={true} - /> - </> - ); -} - -function MediaElementHeader({ - name, - leading, - hasFade, -}: { - name?: string | null; - leading?: ReactNode; - hasFade?: boolean; -}) { - if (!name && !leading) { - return null; - } - - return ( - <div - className={cn( - "absolute top-0 left-0 flex h-5 w-full bg-linear-to-b pt-1", - hasFade && "from-black/30 to-transparent", - )} - > - {leading && <div className="pl-1">{leading}</div>} - {name && ( - <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75"> - {name} - </span> - )} - </div> - ); -} - -function ElementContent({ element, track }: ElementContentProps) { - switch (element.type) { - case "text": - return <TextElementContent element={element} />; - case "effect": - return <EffectElementContent element={element} />; - case "sticker": - return <StickerElementContent element={element} />; - case "graphic": - return <GraphicElementContent element={element} />; - case "audio": - return <AudioElementContent element={element} trackId={track.id} />; - case "video": - case "image": - return <TiledMediaContent element={element} track={track} />; - } -} - -function CopyMenuItem() { - return ( - <ActionMenuItem - action="copy-selected" - icon={<HugeiconsIcon icon={Copy01Icon} />} - > - Copy - </ActionMenuItem> - ); -} - -function MuteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - isMuted, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - isMuted: boolean; -}) { - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return <HugeiconsIcon icon={VolumeMute02Icon} />; - } - return isMuted ? ( - <HugeiconsIcon icon={VolumeOffIcon} /> - ) : ( - <HugeiconsIcon icon={VolumeHighIcon} /> - ); - }; - - return ( - <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}> - {isMuted ? "Unmute" : "Mute"} - </ActionMenuItem> - ); -} - -function VisibilityMenuItem({ - element, - isMultipleSelected, - isCurrentElementSelected, -}: { - element: TimelineElementType; - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; -}) { - const isHidden = canElementBeHidden(element) && element.hidden; - - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return <HugeiconsIcon icon={ViewOffSlashIcon} />; - } - return isHidden ? ( - <HugeiconsIcon icon={ViewIcon} /> - ) : ( - <HugeiconsIcon icon={ViewOffSlashIcon} /> - ); - }; - - return ( - <ActionMenuItem - action="toggle-elements-visibility-selected" - icon={getIcon()} - > - {isHidden ? "Show" : "Hide"} - </ActionMenuItem> - ); -} - -function DeleteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - elementType, - selectedCount, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - elementType: TimelineElementType["type"]; - selectedCount: number; -}) { - return ( - <ActionMenuItem - action="delete-selected" - variant="destructive" - icon={<HugeiconsIcon icon={Delete02Icon} />} - > - {isMultipleSelected && isCurrentElementSelected - ? `Delete ${selectedCount} elements` - : `Delete ${elementType === "text" ? "text" : "clip"}`} - </ActionMenuItem> - ); -} - -function ActionMenuItem({ - action, - children, - ...props -}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { - action: TActionWithOptionalArgs; - children: ReactNode; -}) { - return ( - <ContextMenuItem - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - invokeAction(action); - }} - textRight={getDisplayShortcut({ action })} - {...props} - > - {children} - </ContextMenuItem> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx deleted file mode 100644 index d9218371..00000000 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { useRef } from "react"; -import { - getCenteredLineLeft, - TIMELINE_INDICATOR_LINE_WIDTH_PX, - timelineTimeToSnappedPixels, -} from "@/lib/timeline"; -import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { useEditor } from "@/hooks/use-editor"; -import { - TIMELINE_SCROLLBAR_SIZE_PX, -} from "./layout"; -import { TIMELINE_LAYERS } from "./layers"; - -interface TimelinePlayheadProps { - zoomLevel: number; - hasHorizontalScrollbar: boolean; - rulerRef: React.RefObject<HTMLDivElement | null>; - rulerScrollRef: React.RefObject<HTMLDivElement | null>; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; - timelineRef: React.RefObject<HTMLDivElement | null>; - playheadRef?: React.RefObject<HTMLDivElement | null>; - isSnappingToPlayhead?: boolean; -} - -export function TimelinePlayhead({ - zoomLevel, - hasHorizontalScrollbar, - rulerRef, - rulerScrollRef, - tracksScrollRef, - timelineRef, - playheadRef: externalPlayheadRef, - isSnappingToPlayhead = false, -}: TimelinePlayheadProps) { - const editor = useEditor(); - const duration = editor.timeline.getTotalDuration(); - const internalPlayheadRef = useRef<HTMLDivElement>(null); - const playheadRef = externalPlayheadRef || internalPlayheadRef; - - const { handlePlayheadMouseDown } = useTimelinePlayhead({ - zoomLevel, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - const timelineContainerHeight = - timelineRef.current?.clientHeight ?? - tracksScrollRef.current?.clientHeight ?? - 400; - const totalHeight = Math.max( - 0, - timelineContainerHeight - - (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0), - ); - - const currentTime = editor.playback.getCurrentTime(); - const centerPosition = timelineTimeToSnappedPixels({ - time: currentTime, - zoomLevel, - }); - const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; - const leftPosition = - getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; - - const handlePlayheadKeyDown = ( - event: React.KeyboardEvent<HTMLDivElement>, - ) => { - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; - - event.preventDefault(); - const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator); - const direction = event.key === "ArrowRight" ? 1 : -1; - const now = editor.playback.getCurrentTime(); - const nextTime = Math.max(0, Math.min(duration, now + direction * ticksPerFrame)); - - editor.playback.seek({ time: nextTime }); - }; - - return ( - <div - ref={playheadRef} - role="slider" - aria-label="Timeline playhead" - aria-valuemin={0} - aria-valuemax={duration} - aria-valuenow={currentTime} - tabIndex={0} - className="pointer-events-none absolute" - style={{ - left: `${leftPosition}px`, - top: 0, - height: `${totalHeight}px`, - width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, - zIndex: TIMELINE_LAYERS.playhead, - }} - onKeyDown={handlePlayheadKeyDown} - > - <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" /> - - <button - type="button" - aria-label="Drag playhead" - className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`} - onMouseDown={handlePlayheadMouseDown} - /> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx deleted file mode 100644 index 7b69e820..00000000 --- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { type JSX, useLayoutEffect, useRef } from "react"; -import { - BASE_TIMELINE_PIXELS_PER_SECOND, -} from "@/lib/timeline/scale"; -import { mediaTimeToSeconds } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { TIMELINE_RULER_HEIGHT_PX } from "./layout"; -import { DEFAULT_FPS } from "@/lib/fps/defaults"; -import { useEditor } from "@/hooks/use-editor"; -import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; -import { useScrollPosition } from "@/hooks/timeline/use-scroll-position"; -import { TimelineTick } from "./timeline-tick"; - -interface TimelineRulerProps { - zoomLevel: number; - dynamicTimelineWidth: number; - rulerRef: React.Ref<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLElement | null>; - handleWheel: (e: React.WheelEvent) => void; - handleTimelineContentClick: (e: React.MouseEvent) => void; - handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; - handleRulerMouseDown: (e: React.MouseEvent) => void; -} - -export function TimelineRuler({ - zoomLevel, - dynamicTimelineWidth, - rulerRef, - tracksScrollRef, - handleWheel, - handleTimelineContentClick, - handleRulerTrackingMouseDown, - handleRulerMouseDown, -}: TimelineRulerProps) { - const durationTicks = useEditor((e) => e.timeline.getTotalDuration()); - const durationSeconds = mediaTimeToSeconds({ time: durationTicks }); - const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond; - const effectiveDurationSeconds = Math.max(durationSeconds, visibleDurationSeconds); - const fps = - useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS; - const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ - zoomLevel, - fps, - }); - const tickCount = Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1; - - const { scrollLeft, viewportWidth } = useScrollPosition({ - scrollRef: tracksScrollRef, - }); - - /** - * widens the virtualization buffer during zoom transitions. - * useScrollPosition lags one frame behind the scroll adjustment - * that useLayoutEffect applies after a zoom change. - */ - const prevZoomRef = useRef(zoomLevel); - const isZoomTransition = zoomLevel !== prevZoomRef.current; - const bufferPx = isZoomTransition - ? Math.max(200, (scrollLeft + viewportWidth) * 0.15) - : 200; - - useLayoutEffect(() => { - prevZoomRef.current = zoomLevel; - }, [zoomLevel]); - - const visibleStartTimeSeconds = Math.max( - 0, - (scrollLeft - bufferPx) / pixelsPerSecond, - ); - const visibleEndTimeSeconds = - (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond; - - const startTickIndex = Math.max( - 0, - Math.floor(visibleStartTimeSeconds / tickIntervalSeconds), - ); - const endTickIndex = Math.min( - tickCount - 1, - Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds), - ); - - const timelineTicks: Array<JSX.Element> = []; - for ( - let tickIndex = startTickIndex; - tickIndex <= endTickIndex; - tickIndex += 1 - ) { - const timeSeconds = tickIndex * tickIntervalSeconds; - if (timeSeconds > effectiveDurationSeconds) break; - - const timeTicks = Math.round(timeSeconds * TICKS_PER_SECOND); - const showLabel = shouldShowLabel({ time: timeSeconds, labelIntervalSeconds }); - timelineTicks.push( - <TimelineTick - key={tickIndex} - time={timeTicks} - timeInSeconds={timeSeconds} - zoomLevel={zoomLevel} - fps={fps} - showLabel={showLabel} - />, - ); - } - - return ( - <div - role="slider" - tabIndex={0} - aria-label="Timeline ruler" - aria-valuemin={0} - aria-valuemax={effectiveDurationSeconds} - aria-valuenow={0} - className="relative flex-1 overflow-x-visible" - style={{ height: TIMELINE_RULER_HEIGHT_PX }} - onWheel={handleWheel} - onClick={(event) => { - // Ruler seek already happens on mousedown via playhead scrubbing. - // Forwarding the follow-up click re-enters the selection-clearing path. - if (event.target === event.currentTarget) { - handleTimelineContentClick(event); - } - }} - onMouseDown={handleRulerTrackingMouseDown} - onKeyDown={() => {}} - > - <div - role="none" - ref={rulerRef} - className="relative cursor-default select-none" - style={{ - height: TIMELINE_RULER_HEIGHT_PX, - width: `${dynamicTimelineWidth}px`, - }} - onMouseDown={handleRulerMouseDown} - > - {timelineTicks} - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx deleted file mode 100644 index fca55752..00000000 --- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import type { FrameRate } from "opencut-wasm"; -import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; - -interface TimelineTickProps { - time: number; - timeInSeconds: number; - zoomLevel: number; - fps: FrameRate; - showLabel: boolean; -} - -export function TimelineTick({ - time, - timeInSeconds, - zoomLevel, - fps, - showLabel, -}: TimelineTickProps) { - const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel }); - - if (showLabel) { - const label = formatRulerLabel({ timeInSeconds, fps }); - return ( - <span - className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none" - style={{ left: `${leftPosition}px` }} - > - {label} - </span> - ); - } - - return ( - <div - className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l" - style={{ left: `${leftPosition}px` }} - /> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx deleted file mode 100644 index 80ea10d0..00000000 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { useEditor } from "@/hooks/use-editor"; -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip"; -import { Button } from "@/components/ui/button"; -import { - SplitButton, - SplitButtonLeft, - SplitButtonRight, - SplitButtonSeparator, -} from "@/components/ui/split-button"; -import { Slider } from "@/components/ui/slider"; -import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction"; -import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale"; -import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; -import { ScenesView } from "@/components/editor/scenes-view"; -import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions"; -import { - canToggleSourceAudio, - getSourceAudioActionLabel, - isSourceAudioSeparated, -} from "@/lib/timeline/audio-separation"; -import { hasMediaId } from "@/lib/timeline"; -import { cn } from "@/utils/ui"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Bookmark02Icon, - Delete02Icon, - SnowIcon, - ScissorIcon, - MagnetIcon, - SearchAddIcon, - SearchMinusIcon, - Copy01Icon, - AlignLeftIcon, - AlignRightIcon, - Link02Icon, - Layers01Icon, - Chart03Icon, - Unlink02Icon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { OcRippleIcon } from "@/components/icons"; -import { GraphEditorPopover } from "./graph-editor/popover"; -import { PopoverTrigger } from "@/components/ui/popover"; -import { useGraphEditorController } from "./graph-editor/use-controller"; - -export function TimelineToolbar({ - zoomLevel, - minZoom, - setZoomLevel, -}: { - zoomLevel: number; - minZoom: number; - setZoomLevel: ({ zoom }: { zoom: number }) => void; -}) { - const handleZoom = ({ direction }: { direction: "in" | "out" }) => { - const newZoomLevel = - direction === "in" - ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR) - : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR); - setZoomLevel({ zoom: newZoomLevel }); - }; - - return ( - <ScrollArea className="scrollbar-hidden"> - <div className="flex h-10 items-center justify-between border-b px-2 py-1"> - <ToolbarLeftSection /> - - <SceneSelector /> - - <ToolbarRightSection - zoomLevel={zoomLevel} - minZoom={minZoom} - onZoomChange={(zoom) => setZoomLevel({ zoom })} - onZoom={handleZoom} - /> - </div> - </ScrollArea> - ); -} - -function ToolbarLeftSection() { - const editor = useEditor(); - const mediaAssets = useEditor((currentEditor) => - currentEditor.media.getAssets(), - ); - const { selectedElements } = useElementSelection(); - const graphEditor = useGraphEditorController(); - const isCurrentlyBookmarked = useEditor((e) => - e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), - ); - const selectedElement = - selectedElements.length === 1 - ? (editor.timeline.getElementsWithTracks({ - elements: selectedElements, - })[0] ?? null) - : null; - const selectedMediaAsset = (() => { - if (!selectedElement) { - return null; - } - - const { element } = selectedElement; - if (!hasMediaId(element)) { - return null; - } - - return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; - })(); - const canToggleSelectedSourceAudio = - !!selectedElement && - canToggleSourceAudio(selectedElement.element, selectedMediaAsset); - const sourceAudioLabel = - selectedElement?.element.type === "video" - ? getSourceAudioActionLabel({ - element: selectedElement.element, - }) - : "Extract audio"; - const isSelectedSourceAudioSeparated = - selectedElement?.element.type === "video" && - isSourceAudioSeparated({ - element: selectedElement.element, - }); - - const handleAction = ({ - action, - event, - }: { - action: TActionWithOptionalArgs; - event: React.MouseEvent; - }) => { - event.stopPropagation(); - invokeAction(action); - }; - - return ( - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <ToolbarButton - icon={<HugeiconsIcon icon={ScissorIcon} />} - tooltip="Split element" - onClick={({ event }) => handleAction({ action: "split", event })} - /> - - <ToolbarButton - icon={<HugeiconsIcon icon={AlignLeftIcon} />} - tooltip="Split left" - onClick={({ event }) => handleAction({ action: "split-left", event })} - /> - - <ToolbarButton - icon={<HugeiconsIcon icon={AlignRightIcon} />} - tooltip="Split right" - onClick={({ event }) => - handleAction({ action: "split-right", event }) - } - /> - - <ToolbarButton - icon={ - <HugeiconsIcon - icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon} - /> - } - tooltip={sourceAudioLabel} - disabled={!canToggleSelectedSourceAudio} - onClick={({ event }) => - handleAction({ action: "toggle-source-audio", event }) - } - /> - - <ToolbarButton - icon={<HugeiconsIcon icon={Copy01Icon} />} - tooltip="Duplicate element" - onClick={({ event }) => - handleAction({ action: "duplicate-selected", event }) - } - /> - - <ToolbarButton - icon={<HugeiconsIcon icon={SnowIcon} />} - tooltip="Freeze frame (coming soon)" - disabled={true} - onClick={({ event: _event }) => {}} - /> - - <ToolbarButton - icon={<HugeiconsIcon icon={Delete02Icon} />} - tooltip="Delete element" - onClick={({ event }) => - handleAction({ action: "delete-selected", event }) - } - /> - - <div className="bg-border mx-1 h-6 w-px" /> - - <Tooltip> - <ToolbarButton - icon={<HugeiconsIcon icon={Bookmark02Icon} />} - isActive={isCurrentlyBookmarked} - tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} - onClick={({ event }) => - handleAction({ action: "toggle-bookmark", event }) - } - /> - </Tooltip> - - <GraphEditorPopover - open={graphEditor.open} - onOpenChange={graphEditor.onOpenChange} - value={ - graphEditor.state.status === "ready" - ? graphEditor.state.cubicBezier - : null - } - message={graphEditor.state.message} - componentOptions={graphEditor.state.componentOptions} - activeComponentKey={graphEditor.state.activeComponentKey} - onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange} - onPreviewValue={graphEditor.onPreviewValue} - onCommitValue={graphEditor.onCommitValue} - onCancelPreview={graphEditor.onCancelPreview} - > - <ToolbarButton - icon={<HugeiconsIcon icon={Chart03Icon} />} - tooltip={graphEditor.tooltip} - disabled={!graphEditor.canOpen} - buttonWrapper={(button) => - graphEditor.canOpen ? ( - <PopoverTrigger asChild>{button}</PopoverTrigger> - ) : ( - button - ) - } - /> - </GraphEditorPopover> - </TooltipProvider> - </div> - ); -} - -function SceneSelector() { - const editor = useEditor(); - const currentScene = editor.scenes.getActiveScene(); - - return ( - <div> - <SplitButton className="border-foreground/10 border"> - <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> - <SplitButtonSeparator /> - <ScenesView> - <SplitButtonRight onClick={() => {}}> - <HugeiconsIcon icon={Layers01Icon} className="size-4" /> - </SplitButtonRight> - </ScenesView> - </SplitButton> - </div> - ); -} - -function ToolbarRightSection({ - zoomLevel, - minZoom, - onZoomChange, - onZoom, -}: { - zoomLevel: number; - minZoom: number; - onZoomChange: (zoom: number) => void; - onZoom: (options: { direction: "in" | "out" }) => void; -}) { - const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); - const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); - const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); - const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); - - return ( - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <ToolbarButton - icon={<HugeiconsIcon icon={MagnetIcon} />} - isActive={snappingEnabled} - tooltip="Auto snapping" - onClick={() => toggleSnapping()} - /> - - <ToolbarButton - icon={<OcRippleIcon size={24} className="scale-110" />} - isActive={rippleEditingEnabled} - tooltip="Ripple editing" - onClick={() => toggleRippleEditing()} - /> - </TooltipProvider> - - <div className="bg-border mx-1 h-6 w-px" /> - - <div className="flex items-center gap-1"> - <Button - variant="text" - size="icon" - onClick={() => onZoom({ direction: "out" })} - > - <HugeiconsIcon icon={SearchMinusIcon} /> - </Button> - <Slider - className="w-28" - value={[zoomToSlider({ zoomLevel, minZoom })]} - onValueChange={(values) => - onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom })) - } - min={0} - max={1} - step={0.005} - /> - <Button - variant="text" - size="icon" - onClick={() => onZoom({ direction: "in" })} - > - <HugeiconsIcon icon={SearchAddIcon} /> - </Button> - </div> - </div> - ); -} - -function ToolbarButton({ - icon, - tooltip, - onClick, - disabled, - isActive, - buttonWrapper, -}: { - icon: React.ReactNode; - tooltip: string; - onClick?: ({ event }: { event: React.MouseEvent }) => void; - disabled?: boolean; - isActive?: boolean; - buttonWrapper?: (button: React.ReactElement) => React.ReactElement; -}) { - const button = ( - <Button - variant={isActive ? "secondary" : "text"} - size="icon" - disabled={disabled} - onClick={onClick ? (event) => onClick({ event }) : undefined} - className={cn( - "rounded-sm", - disabled ? "cursor-not-allowed opacity-50" : "", - )} - > - {icon} - </Button> - ); - const trigger = disabled ? ( - <span className="inline-flex">{button}</span> - ) : buttonWrapper ? ( - buttonWrapper(button) - ) : ( - button - ); - - return ( - <Tooltip delayDuration={200}> - <TooltipTrigger asChild>{trigger}</TooltipTrigger> - <TooltipContent>{tooltip}</TooltipContent> - </Tooltip> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx deleted file mode 100644 index 32b1b93d..00000000 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client"; - -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { TimelineElement } from "./timeline-element"; -import type { TimelineTrack } from "@/lib/timeline"; -import type { TimelineElement as TimelineElementType } from "@/lib/timeline"; -import { TIMELINE_LAYERS } from "./layers"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import type { ElementDragState } from "@/lib/timeline"; -import { useEditor } from "@/hooks/use-editor"; - -interface TimelineTrackContentProps { - track: TimelineTrack; - zoomLevel: number; - dragState: ElementDragState; - rulerScrollRef: React.RefObject<HTMLDivElement | null>; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; - lastMouseXRef: React.RefObject<number>; - onResizeStart: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - side: "left" | "right"; - }) => void; - onElementMouseDown: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - }) => void; - onElementClick: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - }) => void; - onTrackMouseDown?: (event: React.MouseEvent) => void; - onTrackMouseUp?: (event: React.MouseEvent) => void; - shouldIgnoreClick?: () => boolean; - targetElementId?: string | null; -} - -export function TimelineTrackContent({ - track, - zoomLevel, - dragState, - rulerScrollRef, - tracksScrollRef, - lastMouseXRef, - onResizeStart, - onElementMouseDown, - onElementClick, - onTrackMouseDown, - onTrackMouseUp, - shouldIgnoreClick, - targetElementId = null, -}: TimelineTrackContentProps) { - const { isElementSelected } = useElementSelection(); - const duration = useEditor((e) => e.timeline.getTotalDuration()); - - useEdgeAutoScroll({ - isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current ?? 0, - rulerScrollRef, - tracksScrollRef, - contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel, - }); - - return ( - <div className="relative size-full"> - <button - type="button" - className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0" - aria-label={`Select ${track.name} track`} - onMouseUp={(event) => { - if (shouldIgnoreClick?.()) return; - onTrackMouseUp?.(event); - }} - onMouseDown={(event) => { - event.preventDefault(); - onTrackMouseDown?.(event); - }} - /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} - <div - className="relative h-full min-w-full" - style={{ zIndex: TIMELINE_LAYERS.trackContent }} - onMouseUp={(event) => { - if (event.target !== event.currentTarget) return; - if (shouldIgnoreClick?.()) return; - onTrackMouseUp?.(event); - }} - onMouseDown={(event) => { - if (event.target !== event.currentTarget) return; - event.preventDefault(); - onTrackMouseDown?.(event); - }} - > - {track.elements.length === 0 ? ( - <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> - ) : ( - track.elements.map((element) => { - const isSelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - - return ( - <TimelineElement - key={element.id} - element={element} - track={track} - zoomLevel={zoomLevel} - isSelected={isSelected} - onResizeStart={({ event, element, side }) => - onResizeStart({ event, element, track, side }) - } - onElementMouseDown={(event, element) => - onElementMouseDown({ event, element, track }) - } - onElementClick={(event, element) => - onElementClick({ event, element, track }) - } - dragState={dragState} - isDropTarget={element.id === targetElementId} - /> - ); - }) - )} - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/track-layout.ts b/apps/web/src/components/editor/panels/timeline/track-layout.ts deleted file mode 100644 index 8f833a57..00000000 --- a/apps/web/src/components/editor/panels/timeline/track-layout.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { TrackType } from "@/lib/timeline"; -import { - KEYFRAME_LANE_HEIGHT_PX, - TIMELINE_TRACK_GAP_PX, - TIMELINE_TRACK_HEIGHTS_PX, -} from "./layout"; - -export function getTrackHeight({ type }: { type: TrackType }): number { - return TIMELINE_TRACK_HEIGHTS_PX[type]; -} - -export function getExpandedTrackHeight({ - type, - expandedLaneCount, -}: { - type: TrackType; - expandedLaneCount: number; -}): number { - return ( - TIMELINE_TRACK_HEIGHTS_PX[type] + - expandedLaneCount * KEYFRAME_LANE_HEIGHT_PX - ); -} - -export function getCumulativeHeightBefore({ - tracks, - trackIndex, - getExtraHeight, -}: { - tracks: Array<{ type: TrackType }>; - trackIndex: number; - getExtraHeight?: (trackIndex: number) => number; -}): number { - return tracks - .slice(0, trackIndex) - .reduce( - (sum, track, i) => - sum + - getTrackHeight({ type: track.type }) + - (getExtraHeight?.(i) ?? 0) + - TIMELINE_TRACK_GAP_PX, - 0, - ); -} - -export function getTotalTracksHeight({ - tracks, - getExtraHeight, -}: { - tracks: Array<{ type: TrackType }>; - getExtraHeight?: (trackIndex: number) => number; -}): number { - const tracksHeight = tracks.reduce( - (sum, track, i) => - sum + getTrackHeight({ type: track.type }) + (getExtraHeight?.(i) ?? 0), - 0, - ); - const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX; - return tracksHeight + gapsHeight; -} |
