From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- .../editor/dialogs/project-info-dialog.tsx | 2 +- .../components/editor/dialogs/shortcuts-dialog.tsx | 2 +- .../editor/dialogs/storage-persistence-dialog.tsx | 41 + apps/web/src/components/editor/editor-header.tsx | 7 +- apps/web/src/components/editor/export-button.tsx | 63 +- apps/web/src/components/editor/mobile-gate.tsx | 6 +- .../editor/panels/assets/drag-overlay.tsx | 2 +- .../editor/panels/assets/draggable-item.tsx | 17 +- .../src/components/editor/panels/assets/tabbar.tsx | 15 +- .../editor/panels/assets/views/assets.tsx | 153 ++-- .../editor/panels/assets/views/base-panel.tsx | 57 ++ .../editor/panels/assets/views/base-view.tsx | 52 -- .../editor/panels/assets/views/captions.tsx | 141 ++-- .../editor/panels/assets/views/effects.tsx | 15 +- .../editor/panels/assets/views/settings-legacy.tsx | 332 -------- .../editor/panels/assets/views/settings.tsx | 168 ---- .../panels/assets/views/settings/background.tsx | 227 ++++++ .../editor/panels/assets/views/settings/index.tsx | 369 +++++++++ .../editor/panels/assets/views/sounds.tsx | 2 +- .../editor/panels/assets/views/stickers.tsx | 538 ++++++++----- .../components/editor/panels/assets/views/text.tsx | 12 +- .../editor/panels/preview/context-menu.tsx | 39 +- .../editor/panels/preview/guide-overlay.tsx | 32 + .../editor/panels/preview/guide-popover.tsx | 100 +++ .../editor/panels/preview/handle-primitives.tsx | 332 ++++++++ .../src/components/editor/panels/preview/index.tsx | 266 ++++--- .../editor/panels/preview/layout-guide-overlay.tsx | 27 - .../editor/panels/preview/mask-handles.tsx | 238 ++++++ .../panels/preview/preview-interaction-overlay.tsx | 109 ++- .../editor/panels/preview/preview-viewport.tsx | 576 ++++++++++++++ .../editor/panels/preview/snap-guides.tsx | 30 +- .../editor/panels/preview/text-edit-overlay.tsx | 149 +++- .../components/editor/panels/preview/toolbar.tsx | 169 ++-- .../editor/panels/preview/transform-handles.tsx | 273 ++----- .../editor/panels/properties/audio-properties.tsx | 3 - .../panels/properties/clip-effects-properties.tsx | 215 ----- .../properties/components/keyframe-toggle.tsx | 32 + .../properties/components/property-param-field.tsx | 194 +++++ .../panels/properties/effect-param-field.tsx | 152 ---- .../editor/panels/properties/effect-properties.tsx | 58 -- .../hooks/use-keyframed-color-property.ts | 4 +- .../hooks/use-keyframed-number-property.ts | 26 +- .../hooks/use-keyframed-param-property.ts | 148 ++++ .../hooks/use-keyframed-vector-property.ts | 190 +++++ .../components/editor/panels/properties/index.tsx | 141 ++-- .../editor/panels/properties/keyframe-toggle.tsx | 32 - .../editor/panels/properties/registry.tsx | 303 +++++++ .../editor/panels/properties/section.tsx | 262 ------ .../editor/panels/properties/sections/blending.tsx | 222 ------ .../editor/panels/properties/sections/index.tsx | 2 - .../panels/properties/sections/transform.tsx | 382 --------- .../panels/properties/stores/properties-store.ts | 18 + .../editor/panels/properties/tabs/audio-tab.tsx | 109 +++ .../editor/panels/properties/tabs/blending-tab.tsx | 230 ++++++ .../editor/panels/properties/tabs/effects-tab.tsx | 309 +++++++ .../editor/panels/properties/tabs/graphic-tab.tsx | 238 ++++++ .../editor/panels/properties/tabs/masks-tab.tsx | 660 +++++++++++++++ .../editor/panels/properties/tabs/speed-tab.tsx | 137 ++++ .../editor/panels/properties/tabs/text-tab.tsx | 762 ++++++++++++++++++ .../panels/properties/tabs/transform-tab.tsx | 424 ++++++++++ .../editor/panels/properties/text-properties.tsx | 732 ----------------- .../editor/panels/properties/video-properties.tsx | 25 - .../editor/panels/timeline/audio-waveform.tsx | 281 ++++--- .../editor/panels/timeline/bookmarks.tsx | 27 +- .../editor/panels/timeline/drag-line.tsx | 5 +- .../components/editor/panels/timeline/index.tsx | 885 +++++++++++++-------- .../editor/panels/timeline/snap-indicator.tsx | 10 +- .../editor/panels/timeline/timeline-element.tsx | 472 ++++++----- .../editor/panels/timeline/timeline-playhead.tsx | 38 +- .../editor/panels/timeline/timeline-ruler.tsx | 26 +- .../editor/panels/timeline/timeline-tick.tsx | 4 +- .../editor/panels/timeline/timeline-toolbar.tsx | 16 +- .../editor/panels/timeline/timeline-track.tsx | 62 +- apps/web/src/components/editor/selection-box.tsx | 50 -- 74 files changed, 8069 insertions(+), 4378 deletions(-) create mode 100644 apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/base-panel.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/base-view.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/settings.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/settings/background.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/settings/index.tsx create mode 100644 apps/web/src/components/editor/panels/preview/guide-overlay.tsx create mode 100644 apps/web/src/components/editor/panels/preview/guide-popover.tsx create mode 100644 apps/web/src/components/editor/panels/preview/handle-primitives.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx create mode 100644 apps/web/src/components/editor/panels/preview/mask-handles.tsx create mode 100644 apps/web/src/components/editor/panels/preview/preview-viewport.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/audio-properties.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx create mode 100644 apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx create mode 100644 apps/web/src/components/editor/panels/properties/components/property-param-field.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/effect-param-field.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/effect-properties.tsx create mode 100644 apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts create mode 100644 apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts delete mode 100644 apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx create mode 100644 apps/web/src/components/editor/panels/properties/registry.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/section.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/sections/blending.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/sections/index.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/sections/transform.tsx create mode 100644 apps/web/src/components/editor/panels/properties/stores/properties-store.ts create mode 100644 apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx create mode 100644 apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx create mode 100644 apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx create mode 100644 apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx create mode 100644 apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx create mode 100644 apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx create mode 100644 apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx create mode 100644 apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/text-properties.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/video-properties.tsx delete mode 100644 apps/web/src/components/editor/selection-box.tsx (limited to 'apps/web/src/components/editor') diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx index 7b736b23..42f99c79 100644 --- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx @@ -6,7 +6,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import type { TProjectMetadata } from "@/types/project"; +import type { TProjectMetadata } from "@/lib/project/types"; import { formatDate } from "@/utils/date"; import { formatTimeCode } from "@/lib/time"; import { Button } from "@/components/ui/button"; diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx index 565fe705..9a353a1c 100644 --- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx @@ -110,7 +110,7 @@ export function ShortcutsDialog({ Keyboard shortcuts - +
{categories.map((category) => (
diff --git a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx new file mode 100644 index 00000000..d391928c --- /dev/null +++ b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useStoragePersistence } from "@/hooks/use-storage-persistence"; + +export function StoragePersistenceDialog() { + const { showDialog, onConfirm, onDismiss } = useStoragePersistence(); + + return ( + !open && onDismiss()}> + + + Don't lose your projects + + +

+ Your browser can automatically delete your projects when storage + runs low. +

+

+ Allow OpenCut to protect them? +

+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index 2dea73e2..8dd730b5 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -47,7 +47,7 @@ function ProjectDropdown() { const [isExiting, setIsExiting] = useState(false); const router = useRouter(); const editor = useEditor(); - const activeProject = editor.project.getActive(); + const activeProject = useEditor((e) => e.project.getActive()); const handleExit = async () => { if (isExiting) return; @@ -136,7 +136,7 @@ function ProjectDropdown() { - }> + }> e.project.getActive()); const [isEditing, setIsEditing] = useState(false); const inputRef = useRef(null); const originalNameRef = useRef(""); @@ -219,6 +219,7 @@ function EditableProjectName() { event.preventDefault(); if (inputRef.current) { inputRef.current.value = originalNameRef.current; + inputRef.current.setSelectionRange(0, 0); } setIsEditing(false); inputRef.current?.blur(); diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index ddf8308d..ec39e7e6 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -14,20 +14,24 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Progress } from "@/components/ui/progress"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/utils/ui"; -import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export"; +import { + getExportMimeType, + getExportFileExtension, + downloadBuffer, +} from "@/lib/export"; import { Check, Copy, Download, RotateCcw } from "lucide-react"; import { EXPORT_FORMAT_VALUES, EXPORT_QUALITY_VALUES, type ExportFormat, type ExportQuality, -} from "@/types/export"; +} from "@/lib/export"; import { Section, SectionContent, SectionHeader, SectionTitle, -} from "@/components/editor/panels/properties/section"; +} from "@/components/section"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; @@ -42,8 +46,8 @@ function isExportQuality(value: string): value is ExportQuality { export function ExportButton() { const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); const editor = useEditor(); - - const hasProject = !!editor.project.getActiveOrNull(); + const activeProject = useEditor((e) => e.project.getActiveOrNull()); + const hasProject = !!activeProject; const handlePopoverOpenChange = ({ open }: { open: boolean }) => { if (!open) { @@ -54,7 +58,10 @@ export function ExportButton() { }; return ( - handlePopoverOpenChange({ open })}> + handlePopoverOpenChange({ open })} + >
- + -
+ {error && ( +
+

{error}

+
+ )} + + +
+ + + +
); } diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx index c9b8b2dd..74128df6 100644 --- a/apps/web/src/components/editor/panels/assets/views/effects.tsx +++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx @@ -1,19 +1,16 @@ "use client"; import { useEffect, useRef, useCallback } from "react"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects"; -import { - effectPreviewService, - onPreviewImageReady, -} from "@/services/renderer/effect-preview"; +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 "@/types/effects"; +import type { EffectDefinition } from "@/lib/effects/types"; export function EffectsView() { - const effects = getAllEffects(); + const effects = effectsRegistry.getAll(); return ( @@ -50,7 +47,7 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) { }; render(); - return onPreviewImageReady({ callback: render }); + return effectPreviewService.onPreviewImageReady({ callback: render }); }, [effectType]); return ; diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx deleted file mode 100644 index 457c08a9..00000000 --- a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx +++ /dev/null @@ -1,332 +0,0 @@ -// @ts-nocheck - -"use client"; - -import Image from "next/image"; -import { memo, useCallback, useMemo } from "react"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - BLUR_INTENSITY_PRESETS, - DEFAULT_BLUR_INTENSITY, - DEFAULT_COLOR, - FPS_PRESETS, -} from "@/constants/project-constants"; -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 { useEditorStore } from "@/stores/editor-store"; -import { dimensionToAspectRatio } from "@/utils/geometry"; -import { cn } from "@/utils/ui"; -import { - PropertyGroup, - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "@/components/editor/panels/properties/section"; - -export function SettingsView() { - return ( - -
- - -
-
- ); -} - -function ProjectInfoView() { - const editor = useEditor(); - const activeProject = editor.project.getActive(); - const { canvasPresets } = useEditorStore(); - - const findPresetIndexByAspectRatio = ({ - presets, - targetAspectRatio, - }: { - presets: Array<{ width: number; height: number }>; - targetAspectRatio: string; - }) => { - for (let index = 0; index < presets.length; index++) { - const preset = presets[index]; - const presetAspectRatio = dimensionToAspectRatio({ - width: preset.width, - height: preset.height, - }); - if (presetAspectRatio === targetAspectRatio) { - return index; - } - } - return -1; - }; - - const currentCanvasSize = activeProject.settings.canvasSize; - const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); - const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; - const presetIndex = findPresetIndexByAspectRatio({ - presets: canvasPresets, - targetAspectRatio: currentAspectRatio, - }); - const originalPresetValue = "original"; - const selectedPresetValue = - presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; - - const handleAspectRatioChange = ({ value }: { value: string }) => { - if (value === originalPresetValue) { - const canvasSize = originalCanvasSize ?? currentCanvasSize; - editor.project.updateSettings({ - settings: { canvasSize }, - }); - return; - } - const index = parseInt(value, 10); - const preset = canvasPresets[index]; - if (preset) { - editor.project.updateSettings({ settings: { canvasSize: preset } }); - } - }; - - const handleFpsChange = (value: string) => { - const fps = parseFloat(value); - editor.project.updateSettings({ settings: { fps } }); - }; - - return ( -
- - Name - {activeProject.metadata.name} - - - - Aspect ratio - - - - - - - Frame rate - - - - -
- ); -} - -const BlurPreview = memo( - ({ - blur, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - isSelected: boolean; - onSelect: () => void; - }) => ( - - ), -); - -BlurPreview.displayName = "BlurPreview"; - -const BackgroundPreviews = memo( - ({ - backgrounds, - currentBackgroundColor, - isColorBackground, - handleColorSelect, - useBackgroundColor = false, - }: { - backgrounds: string[]; - currentBackgroundColor: string; - isColorBackground: boolean; - handleColorSelect: ({ bg }: { bg: string }) => void; - useBackgroundColor?: boolean; - }) => { - return useMemo( - () => - backgrounds.map((bg, index) => ( - + ); + }, +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + onSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + onSelect: (bg: string) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg) => ( + + ); +} + +function AspectRatioPreview({ ratio }: { ratio?: string }) { + if (!ratio) return null; + + const [w, h] = ratio.split(":").map(Number); + const maxSize = 16; + const width = w >= h ? maxSize : (w / h) * maxSize; + const height = h >= w ? maxSize : (h / w) * maxSize; + + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index b1c57c3d..4fb43ac2 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -24,7 +24,7 @@ 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 "@/types/sounds"; +import type { SavedSound, SoundEffect } from "@/lib/sounds/types"; import { cn } from "@/utils/ui"; import { FavouriteIcon, diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 671effec..af489630 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -2,81 +2,110 @@ import Image from "next/image"; import type { CSSProperties } from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; 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 { useEditor } from "@/hooks/use-editor"; +import { resolveStickerIntrinsicSize } from "@/lib/stickers"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - resolveStickerId, - type StickerItem as StickerData, + buildGraphicElement, + buildStickerElement, +} from "@/lib/timeline/element-utils"; +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +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, - ClockIcon, - MultiplicationSignIcon, - Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Spinner } from "@/components/ui/spinner"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { OcSlidersVerticalIcon } from "@opencut/ui/icons"; -import type { StickerCategory } from "@/types/stickers"; -import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; -import { parseStickerId } from "@/lib/stickers/sticker-id"; export function StickersView() { - const { selectedCategory, setSelectedCategory } = useStickersStore(); + const { + browseContent, + browseStickers, + searchQuery, + searchStickers, + selectedCategory, + setSearchQuery, + setSelectedCategory, + viewMode, + } = useStickersStore(); + + useEffect(() => { + if (viewMode === "browse" && !browseContent) { + void browseStickers(); + } + }, [browseContent, browseStickers, viewMode]); return ( - - { + setSearchQuery({ query: e.target.value }); + void searchStickers({ query: e.target.value }); + }} + showClearIcon + onClear={() => { + setSearchQuery({ query: "" }); + void searchStickers({ query: "" }); + }} + className="w-full" + containerClassName="w-full" + /> +
+ +
+
+
- - - - - {Object.entries(STICKER_CATEGORIES).map(([category, label]) => ( - + {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { + const isActive = key === selectedCategory; + return ( + - - + + ); + })} +
- } - > - - +
+ +
+
+
); } @@ -92,10 +121,10 @@ function StickerGrid({ "--sticker-max"?: string; } = { gridTemplateColumns: shouldCapSize - ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" - : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", - "--sticker-min": "96px", - ...(shouldCapSize ? { "--sticker-max": "160px" } : {}), + ? "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 ( @@ -107,6 +136,18 @@ function StickerGrid({ ); } +function StickerRow({ items }: { items: StickerData[] }) { + return ( +
+ {items.map((item) => ( +
+ +
+ ))} +
+ ); +} + function EmptyView({ message }: { message: string }) { return (
@@ -122,88 +163,170 @@ function EmptyView({ message }: { message: string }) { ); } +function RegionBanner({ region }: { region: string }) { + return ( +
+ + {region} +
+ ); +} + function StickersContentView() { const { + browseContent, + clearRecentStickers, + isBrowsing, + isSearching, searchQuery, - viewMode, searchResults, - recentStickers, - isSearching, - clearRecentStickers, + selectedCategory, + setSelectedCategory, + viewMode, } = useStickersStore(); - const itemsToDisplay = useMemo(() => { - if (viewMode === "search" && searchResults) { - return searchResults.items; + if (viewMode === "search") { + if (isSearching) { + return ( +
+ +
+ ); } - return []; - }, [viewMode, searchResults]); + if (searchResults?.items.length) { + const normalizedQuery = searchQuery.trim().toLowerCase(); + const isRegionSearch = + selectedCategory === "flags" && + resolveQueryToRegions({ query: normalizedQuery }) !== null; + const regionLabel = getRegionLabel({ query: normalizedQuery }); - const recentStickerItems = useMemo(() => { - const items: StickerData[] = []; - for (const stickerId of recentStickers) { - const recentStickerItem = toRecentStickerItem({ stickerId }); - if (recentStickerItem) { - items.push(recentStickerItem); - } + return ( +
+ {isRegionSearch && } +
+ + {searchResults.total} results + +
+ +
+ ); } - return items; - }, [recentStickers]); + + // "all" tab search — sections are in browseContent, fall through to section rendering below + if (selectedCategory !== "all" && searchQuery) { + return ; + } + } + + if (isBrowsing && !browseContent) { + return ( +
+ +
+ ); + } + + if (!browseContent?.sections.length) { + const categoryLabel = STICKER_CATEGORIES[selectedCategory]; + return ( + + ); + } return ( -
- {recentStickerItems.length > 0 && viewMode === "browse" && ( -
-
- - Recent - - - - - - -

Clear recent stickers

-
-
-
+
+ {browseContent.sections.map((section) => ( + { + setSelectedCategory({ category }); + }} + /> + ))} +
+ ); +} + +function StickerSection({ + section, + onClearRecent, + onSeeAll, +}: { + section: StickerBrowseSection; + onClearRecent: () => void; + onSeeAll: (category: StickerCategory) => void; +}) { + const hasHeader = + Boolean(section.title) || section.id === "recent" || section.action; + + return ( +
+ {hasHeader && ( +
+ {section.title ? ( +

{section.title}

+ ) : ( +
+ )} + +
+ {section.id === "recent" && ( + + )} + + {section.action?.type === "see-all" && section.action.category && ( + + )}
-
)} - {viewMode === "search" && ( -
- {isSearching ? ( -
- -
- ) : searchResults?.items.length ? ( -
-
- - {searchResults.total} results - -
- -
- ) : searchQuery ? ( - - ) : null} -
+ {section.layout === "row" ? ( + + ) : ( + )}
); @@ -212,65 +335,116 @@ function StickersContentView() { interface StickerItemProps { item: StickerData; shouldCapSize?: boolean; + containerClassName?: string; } -function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { - const { addingSticker, addStickerToTimeline } = useStickersStore(); - const isAdding = addingSticker === item.id; +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 { - await addStickerToTimeline({ - stickerId: item.id, - name: item.name, + const currentTime = editor.playback.getCurrentTime(); + + let element: + | ReturnType + | ReturnType; + 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 = hasImageError ? ( -
- - {displayName} - -
- ) : ( -
- {displayName} { - setHasImageError(true); - }} - loading="lazy" - unoptimized - /> + const preview = ( +
+ {hasImageError ? ( + + {displayName} + + ) : ( + {displayName} setHasImageError(true)} + loading="lazy" + unoptimized + /> + )}
); + 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 (
{isAdding && (
@@ -299,36 +468,3 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
); } - -function getStickerNameFromId({ stickerId }: { stickerId: string }): string { - const stickerIdParts = stickerId.split(":"); - if (stickerIdParts.length <= 1) { - return stickerId; - } - return ( - stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ?? - stickerId - ); -} - -function toRecentStickerItem({ - stickerId, -}: { - stickerId: string; -}): StickerData | null { - try { - const { providerId } = parseStickerId({ stickerId }); - return { - id: stickerId, - provider: providerId, - name: getStickerNameFromId({ stickerId }), - previewUrl: resolveStickerId({ - stickerId, - options: { width: 64, height: 64 }, - }), - metadata: {}, - }; - } catch { - return null; - } -} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 3673acc3..075cc25b 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,7 +1,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; import { buildTextElement } from "@/lib/timeline/element-utils"; export function TextView() { @@ -12,7 +12,7 @@ export function TextView() { if (!activeScene) return; const element = buildTextElement({ - raw: DEFAULT_TEXT_ELEMENT, + raw: DEFAULTS.text.element, startTime: currentTime, }); @@ -33,9 +33,9 @@ export function TextView() { } dragData={{ id: "temp-text-id", - type: DEFAULT_TEXT_ELEMENT.type, - name: DEFAULT_TEXT_ELEMENT.name, - content: DEFAULT_TEXT_ELEMENT.content, + type: DEFAULTS.text.element.type, + name: DEFAULTS.text.element.name, + content: DEFAULTS.text.element.content, }} aspectRatio={1} onAddToTimeline={handleAddToTimeline} diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx index d4bcdcae..13226378 100644 --- a/apps/web/src/components/editor/panels/preview/context-menu.tsx +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -4,9 +4,12 @@ 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, @@ -16,25 +19,55 @@ export function PreviewContextMenu({ containerRef: React.RefObject; }) { 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 ( + + Fit to screen + + Full screen - editor.renderer.saveSnapshot()} inset> + Save snapshot + + Copy snapshot + + setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) } > - Show bookmarks + Show bookmark notes - Show grid ); } diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx new file mode 100644 index 00000000..ad7a621f --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx @@ -0,0 +1,32 @@ +"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 ( +
+ {guide.renderOverlay({ + width: viewport.sceneWidth, + height: viewport.sceneHeight, + })} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx new file mode 100644 index 00000000..e6d3df51 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/guide-popover.tsx @@ -0,0 +1,100 @@ +"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 ( + + {children} + +
+ +
+ {GUIDE_REGISTRY.map((guide) => ( + toggleGuide(guide.id)} + /> + ))} +
+
+ + {options && ( + + +
{options}
+
+ )} +
+
+
+ ); +} + +function GridItem({ + label, + preview, + isSelected, + onClick, +}: { + label: string; + preview: React.ReactNode; + isSelected?: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx new file mode 100644 index 00000000..44b8a7f1 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx @@ -0,0 +1,332 @@ +"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 "cursor-ew-resize"; + if (normalized < 67.5) return "cursor-nwse-resize"; + if (normalized < 112.5) return "cursor-ns-resize"; + return "cursor-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 ( + + ); +} + +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 ( + +
+ + ); +} + +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 ( + +
+ + ); +} + +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 ( + + + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +export function LineOverlay({ + start, + end, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + start: { x: number; y: number }; + end: { x: number; y: number }; + 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 && ( +
+ )} +
+ + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index df60db01..c625b9bf 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useRef } from "react"; +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"; @@ -12,50 +12,70 @@ import { buildScene } from "@/services/renderer/scene-builder"; import { getLastFrameTime } from "@/lib/time"; 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 editor = useEditor(); - const activeProject = editor.project.getActive(); + const canvasSize = useEditor( + (e) => e.project.getActive()?.settings.canvasSize, + ); return { - width: activeProject?.settings.canvasSize.width, - height: activeProject?.settings.canvasSize.height, + 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(null); - const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); + const { toggleFullscreen } = useFullscreen({ containerRef }); return (
-
- - -
- +
); } function RenderTreeController() { const editor = useEditor(); - const tracks = editor.timeline.getTracks(); - const mediaAssets = editor.media.getAssets(); - const activeProject = editor.project.getActive(); + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const activeProject = useEditor((e) => e.project.getActive()); const { width, height } = usePreviewSize(); @@ -79,23 +99,30 @@ function RenderTreeController() { } function PreviewCanvas({ - onToggleFullscreen, containerRef, + onToggleFullscreen, }: { - onToggleFullscreen: () => void; containerRef: React.RefObject; + onToggleFullscreen: () => void; }) { const canvasRef = useRef(null); - const outerContainerRef = useRef(null); - const canvasBoundsRef = useRef(null); + const viewportRef = useRef(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef(null); const renderingRef = useRef(false); const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); - const containerSize = useContainerSize({ containerRef: outerContainerRef }); + const viewportSize = useContainerSize({ containerRef: viewportRef }); const editor = useEditor(); - const activeProject = editor.project.getActive(); + 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({ @@ -105,37 +132,6 @@ function PreviewCanvas({ }); }, [nativeWidth, nativeHeight, activeProject.settings.fps]); - const displaySize = useMemo(() => { - if ( - !nativeWidth || - !nativeHeight || - containerSize.width === 0 || - containerSize.height === 0 - ) { - return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 }; - } - - const paddingBuffer = 4; - const availableWidth = containerSize.width - paddingBuffer; - const availableHeight = containerSize.height - paddingBuffer; - - const aspectRatio = nativeWidth / nativeHeight; - const containerAspect = availableWidth / availableHeight; - - const displayWidth = - containerAspect > aspectRatio - ? availableHeight * aspectRatio - : availableWidth; - const displayHeight = - containerAspect > aspectRatio - ? availableHeight - : availableWidth / aspectRatio; - - return { width: displayWidth, height: displayHeight }; - }, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]); - - const renderTree = editor.renderer.getRenderTree(); - const render = useCallback(() => { if (canvasRef.current && renderTree && !renderingRef.current) { const time = editor.playback.getCurrentTime(); @@ -168,44 +164,130 @@ function PreviewCanvas({ useRafLoop(render); + useEffect(() => { + const container = viewportRef.current; + if (!container) return; + + let pendingZoomDelta = 0; + let pendingPanDeltaX = 0; + let pendingPanDeltaY = 0; + let zoomRafId: ReturnType | null = null; + let panRafId: ReturnType | 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 ( -
- - -
- +
+
+ + +
+ + + + {overlays.bookmarks && } +
+
+ - - {overlays.bookmarks && } -
- - - -
+ +
+ +
+ ); } diff --git a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx deleted file mode 100644 index 44efb4c8..00000000 --- a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { usePreviewStore } from "@/stores/preview-store"; -import Image from "next/image"; - -function TikTokGuide() { - return ( -
- TikTok layout guide -
- ); -} - -export function LayoutGuideOverlay() { - const { layoutGuide } = usePreviewStore(); - - if (layoutGuide.platform === null) return null; - if (layoutGuide.platform === "tiktok") return ; - - return null; -} diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx new file mode 100644 index 00000000..0c11f27a --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/mask-handles.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useMaskHandles } from "@/hooks/use-mask-handles"; +import { masksRegistry } from "@/lib/masks"; +import type { SnapLine } from "@/lib/preview/preview-snap"; +import type { ParamValues } from "@/lib/params"; +import type { RectangleMaskParams } from "@/lib/masks/types"; +import { + CornerHandle, + EdgeHandle, + IconHandle, + LineOverlay, + BoundingBoxOutline, + ShapeOutline, +} from "./handle-primitives"; +import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons"; + +function hasRectangleOutlineParams( + params: ParamValues, +): params is RectangleMaskParams { + return ( + typeof params.centerX === "number" && + typeof params.centerY === "number" && + typeof params.width === "number" && + typeof params.height === "number" + ); +} + +export function MaskHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: (lines: SnapLine[]) => void; +}) { + const viewport = usePreviewViewport(); + const { + selectedWithMask, + handlePositions, + linePoints, + handlePointerDown, + handlePointerMove, + handlePointerUp, + } = useMaskHandles({ onSnapLinesChange }); + + if (!selectedWithMask) return null; + + const toOverlay = ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => + viewport.canvasToOverlay({ + canvasX, + canvasY, + }); + + const def = masksRegistry.get(selectedWithMask.mask.type); + const { bounds } = selectedWithMask; + const maskRotation = selectedWithMask.mask.params.rotation; + + const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); + + const rectangleOutlineProps = hasRectangleOutlineParams( + selectedWithMask.mask.params, + ) + ? { + center: toOverlay({ + canvasX: + bounds.cx + selectedWithMask.mask.params.centerX * bounds.width, + canvasY: + bounds.cy + selectedWithMask.mask.params.centerY * bounds.height, + }), + outlineWidth: + selectedWithMask.mask.params.width * bounds.width * scaleX, + outlineHeight: + selectedWithMask.mask.params.height * bounds.height * scaleY, + rotation: maskRotation, + } + : null; + + const onPointerMove = (event: React.PointerEvent) => + handlePointerMove({ event }); + const onPointerUp = () => handlePointerUp(); + + return ( +
+ {def.overlayShape === "line" && linePoints && ( + + handlePointerDown({ event, handleId: "position" }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + )} + {def.overlayShape === "box" && rectangleOutlineProps && ( + def.buildOverlayPath ? ( + <> + + + handlePointerDown({ event, handleId: "position" }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + + ) : ( + + handlePointerDown({ event, handleId: "position" }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ) + )} + {handlePositions.map((handle) => { + const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); + + if (handle.id === "rotation") { + return ( + + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.id === "feather") { + return ( + + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.id === "right" || handle.id === "left") { + return ( + + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.id === "bottom" || handle.id === "top") { + return ( + + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if ( + handle.id === "top-left" || + handle.id === "top-right" || + handle.id === "bottom-left" || + handle.id === "bottom-right" || + handle.id === "scale" + ) { + return ( + + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + return ( + + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} +
+ ); +} 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 index 4c33a1a1..65c11726 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,54 +1,117 @@ +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([]); + 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; -export function PreviewInteractionOverlay({ - canvasRef, - containerRef, -}: { - canvasRef: React.RefObject; - containerRef: React.RefObject; -}) { const { onPointerDown, onPointerMove, onPointerUp, onDoubleClick, - snapLines, editingText, commitTextEdit, - cancelTextEdit, - } = usePreviewInteraction({ canvasRef }); + } = 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); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + const x = e.nativeEvent.offsetX; + const y = e.nativeEvent.offsetY; + const outsideCanvas = + x < viewport.sceneLeft || + x > viewport.sceneLeft + viewport.sceneWidth || + y < viewport.sceneTop || + y > viewport.sceneTop + viewport.sceneHeight; + if (outsideCanvas) { + e.stopPropagation(); + } + }; return (
- {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
e.preventDefault()} + onContextMenu={handleContextMenu} /> {editingText ? ( + ) : isMaskMode ? ( + ) : ( - + )} - +
); } diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx new file mode 100644 index 00000000..e38183af --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx @@ -0,0 +1,576 @@ +"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 "@/constants/editor-constants"; + +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(null); + +interface PreviewViewportProviderProps { + children: React.ReactNode; + value: PreviewViewportContextValue; +} + +interface PreviewViewportStateOptions { + canvasHeight: number; + canvasWidth: number; + viewportHeight: number; + viewportRef: React.RefObject; + 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 ( + + {children} + + ); +} + +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(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 index 4aa714a1..744304ea 100644 --- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -1,43 +1,25 @@ "use client"; -import { useEditor } from "@/hooks/use-editor"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import type { SnapLine } from "@/lib/preview/preview-snap"; -import { positionToOverlay } from "@/lib/preview/preview-coords"; -export function SnapGuides({ - lines, - canvasRef, - containerRef, -}: { - lines: SnapLine[]; - canvasRef: React.RefObject; - containerRef: React.RefObject; -}) { - const editor = useEditor(); - const canvasSize = editor.project.getActive().settings.canvasSize; - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); +export function SnapGuides({ lines }: { lines: SnapLine[] }) { + const viewport = usePreviewViewport(); - if (!canvasRect || !containerRect || lines.length === 0) { + if (lines.length === 0) { return null; } const toOverlayX = (logicalX: number) => - positionToOverlay({ + viewport.positionToOverlay({ positionX: logicalX, positionY: 0, - canvasRect, - containerRect, - canvasSize, }).x; const toOverlayY = (logicalY: number) => - positionToOverlay({ + viewport.positionToOverlay({ positionX: 0, positionY: logicalY, - canvasRect, - containerRect, - canvasSize, }).y; return ( 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 index 26649b26..d3c61a24 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -1,38 +1,89 @@ "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 "@/types/timeline"; +import type { TextElement } from "@/lib/timeline"; import { - positionToOverlay, - getDisplayScale, -} from "@/lib/preview/preview-coords"; -import { - DEFAULT_LINE_HEIGHT, FONT_SIZE_SCALE_REFERENCE, } from "@/constants/text-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { + getMetricAscent, + getMetricDescent, + setCanvasLetterSpacing, +} from "@/lib/text/layout"; + +let cachedCanvas: HTMLCanvasElement | null = null; + +function getMeasurementContext(): CanvasRenderingContext2D | null { + if (!cachedCanvas) cachedCanvas = document.createElement("canvas"); + return cachedCanvas.getContext("2d"); +} + +/** + * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than + * actualBoundingBox to model where the browser places the baseline within the + * line box, then measures actual glyph bounds to find the visual center. + */ +function measureCSSVisualCenterOffset({ + lines, + fontString, + letterSpacingPx, + lineHeightPx, + displayFontSize, +}: { + lines: string[]; + fontString: string; + letterSpacingPx: number; + lineHeightPx: number; + displayFontSize: number; +}): number { + const ctx = getMeasurementContext(); + if (!ctx) return 0; + + ctx.font = fontString; + ctx.textBaseline = "alphabetic"; + setCanvasLetterSpacing({ ctx, letterSpacingPx }); + + const probe = ctx.measureText("M"); + const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8; + const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2; + const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2; -const TEXT_BACKGROUND_PADDING = "4px 8px"; -const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06; + let visualTop = Number.POSITIVE_INFINITY; + let visualBottom = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < lines.length; i++) { + const metrics = ctx.measureText(lines[i] || " "); + const baseline = i * lineHeightPx + halfLeading + fontAscent; + visualTop = Math.min( + visualTop, + baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }), + ); + visualBottom = Math.max( + visualBottom, + baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }), + ); + } + + const cssBlockHeight = lines.length * lineHeightPx; + return (visualTop + visualBottom) / 2 - cssBlockHeight / 2; +} export function TextEditOverlay({ - canvasRef, - containerRef, trackId, elementId, element, onCommit, - onCancel, }: { - canvasRef: React.RefObject; - containerRef: React.RefObject; trackId: string; elementId: string; element: TextElement; onCommit: () => void; - onCancel: () => void; }) { const editor = useEditor(); + const viewport = usePreviewViewport(); const divRef = useRef(null); useEffect(() => { @@ -60,59 +111,67 @@ export function TextEditOverlay({ const { key } = event; if (key === "Escape") { event.preventDefault(); - onCancel(); + onCommit(); return; } }, - [onCancel], + [onCommit], ); - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); const canvasSize = editor.project.getActive().settings.canvasSize; - if (!canvasRect || !containerRect || !canvasSize) return null; + if (!canvasSize) return null; - const { x: posX, y: posY } = positionToOverlay({ + const { x: posX, y: posY } = viewport.positionToOverlay({ positionX: element.transform.position.x, positionY: element.transform.position.y, - canvasRect, - containerRect, - canvasSize, }); - const { x: displayScaleX } = getDisplayScale({ - canvasRect, - canvasSize, - }); + const { x: displayScaleX } = viewport.getDisplayScale(); const displayFontSize = element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * displayScaleX; - const verticalAlignmentOffset = - displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM; - - const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT; + const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const letterSpacing = element.letterSpacing ?? 0; + const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX; + const lineHeightPx = displayFontSize * lineHeight; + const lines = (element.content || "").split("\n"); + const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`; + + const cssVisualCenterOffset = measureCSSVisualCenterOffset({ + lines, + fontString, + letterSpacingPx: displayLetterSpacing, + lineHeightPx, + displayFontSize, + }); + + const bg = element.background; const shouldShowBackground = - element.background.enabled && - element.background.color && - element.background.color !== "transparent"; - const backgroundColor = shouldShowBackground - ? element.background.color - : "transparent"; + bg.enabled && bg.color && bg.color !== "transparent"; + const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; + const displayPaddingX = shouldShowBackground + ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * + fontSizeRatio * + displayScaleX + : 0; + const displayPaddingY = shouldShowBackground + ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * + fontSizeRatio * + displayScaleX + : 0; return (
@@ -131,13 +190,15 @@ export function TextEditOverlay({ fontWeight, fontStyle, textAlign: element.textAlign, - letterSpacing: `${letterSpacing}px`, + letterSpacing: `${displayLetterSpacing}px`, lineHeight, color: element.color, - backgroundColor, - minHeight: displayFontSize * lineHeight, + backgroundColor: shouldShowBackground ? bg.color : "transparent", + minHeight: lineHeightPx, textDecoration: element.textDecoration ?? "none", - padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0, + padding: shouldShowBackground + ? `${displayPaddingY}px ${displayPaddingX}px` + : 0, minWidth: 1, }} onInput={handleInput} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx index 7f5cd9bc..56cb784a 100644 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useEffect } from "react"; import { useEditor } from "@/hooks/use-editor"; import { formatTimeCode } from "@/lib/time"; import { invokeAction } from "@/lib/actions"; @@ -7,74 +8,144 @@ 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 { OcSocialIcon } from "@opencut/ui/icons"; +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 "@/constants/editor-constants"; +import { usePreviewViewport } from "./preview-viewport"; +import { GridPopover } from "./guide-popover"; +import { usePreviewStore } from "@/stores/preview-store"; export function PreviewToolbar({ - isFullscreen, onToggleFullscreen, }: { - isFullscreen: boolean; onToggleFullscreen: () => void; }) { - const editor = useEditor(); - const isPlaying = editor.playback.getIsPlaying(); - const currentTime = editor.playback.getCurrentTime(); - const totalDuration = editor.timeline.getTotalDuration(); - const fps = editor.project.getActive().settings.fps; + const activeGuide = usePreviewStore((state) => state.activeGuide); + const activeGuideDefinition = getGuideById(activeGuide); return (
-
- editor.playback.seek({ time })} - className="text-center" - /> - / - - {formatTimeCode({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - -
- - - + +
- + - + +
); } + +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 ( +
+ editor.playback.seek({ time })} + className="text-center" + /> + / + + {formatTimeCode({ + timeInSeconds: totalDuration, + format: "HH:MM:SS:FF", + fps, + })} + +
+ ); +} + +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 ( + + ); +} + +function PlayPauseButton() { + const isPlaying = useEditor((e) => e.playback.getIsPlaying()); + + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx index 2fcf8096..88332625 100644 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -1,112 +1,55 @@ "use client"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useTransformHandles } from "@/hooks/use-transform-handles"; -import { useEditor } from "@/hooks/use-editor"; import { isVisualElement } from "@/lib/timeline/element-utils"; -import { SnapGuides } from "./snap-guides"; -import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; +import { + getCornerPosition, + getEdgeHandlePosition, + getRotationHandlePosition, + 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 HANDLE_SIZE = 10; -const ROTATION_HANDLE_OFFSET = 24; -const ROTATION_HANDLE_RADIUS = 10; -const CORNER_HIT_AREA_SIZE = 18; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; const CORNERS: Corner[] = [ "top-left", "top-right", "bottom-left", "bottom-right", ]; - -export function getCornerPosition({ - bounds, - corner, -}: { - bounds: ElementBounds; - corner: Corner; -}): { x: number; y: number } { - const halfW = bounds.width / 2; - const halfH = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; - const localY = - corner === "top-left" || corner === "top-right" ? -halfH : halfH; - - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { - x: number; - y: number; -} { - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET; - return { - x: bounds.cx - localY * sin, - y: bounds.cy + localY * cos, - }; -} - -export function getOverlayContext({ - canvasRef, - containerRef, -}: { - canvasRef: React.RefObject; - containerRef: React.RefObject; -}): { - canvasRect: DOMRect; - containerRect: DOMRect; -} | null { - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); - if (!canvasRect || !containerRect) return null; - return { canvasRect, containerRect }; -} +const EDGES: Edge[] = ["right", "left", "bottom"]; export function TransformHandles({ - canvasRef, - containerRef, + onSnapLinesChange, }: { - canvasRef: React.RefObject; - containerRef: React.RefObject; + onSnapLinesChange?: OnSnapLinesChange; }) { + const viewport = usePreviewViewport(); const { selectedWithBounds, hasVisualSelection, - snapLines, handleCornerPointerDown, + handleEdgePointerDown, handleRotationPointerDown, handlePointerMove, handlePointerUp, - } = useTransformHandles({ canvasRef }); - - const editor = useEditor(); - const canvasSize = editor.project.getActive().settings.canvasSize; + } = useTransformHandles({ onSnapLinesChange }); if (!hasVisualSelection || !selectedWithBounds) return null; - const overlayContext = getOverlayContext({ canvasRef, containerRef }); - if (!overlayContext) return null; - const { bounds, element } = selectedWithBounds; if (!isVisualElement(element)) return null; - const { canvasRect, containerRect } = overlayContext; - const displayScale = getDisplayScale({ canvasRect, canvasSize }); + const displayScale = viewport.getDisplayScale(); const toOverlay = ({ canvasX, @@ -115,31 +58,30 @@ export function TransformHandles({ canvasX: number; canvasY: number; }) => - canvasToOverlay({ + viewport.canvasToOverlay({ canvasX, canvasY, - canvasRect, - containerRect, - canvasSize, }); const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); - const outlineWidth = bounds.width * displayScale.x; - const outlineHeight = bounds.height * displayScale.y; + const outlineWidth = Math.abs(bounds.width) * displayScale.x; + const outlineHeight = Math.abs(bounds.height) * displayScale.y; - const rotationHandle = getRotationHandlePosition({ bounds }); + const rotationHandleCanvas = getRotationHandlePosition({ bounds }); const rotationHandleScreen = toOverlay({ - canvasX: rotationHandle.x, - canvasY: rotationHandle.y, + canvasX: rotationHandleCanvas.x, + canvasY: rotationHandleCanvas.y, }); + const onPointerMove = (event: React.PointerEvent) => + handlePointerMove({ event }); + const onPointerUp = () => handlePointerUp(); + return ( -
- +
handleCornerPointerDown({ event, corner }) } - onPointerMove={(event) => handlePointerMove({ event })} - onPointerUp={(event) => handlePointerUp({ event })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} + {EDGES.map((edge) => { + const edgePosition = getEdgeHandlePosition({ bounds, edge }); + const screen = toOverlay({ + canvasX: edgePosition.x, + canvasY: edgePosition.y, + }); + return ( + handleEdgePointerDown({ event, edge })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} /> ); })} - handleRotationPointerDown({ event })} - onPointerMove={(event) => handlePointerMove({ event })} - onPointerUp={(event) => handlePointerUp({ event })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} />
); } - -function BoundingBoxOutline({ - center, - outlineWidth, - outlineHeight, - rotation, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; -}) { - return ( -
- ); -} - -function CornerHandle({ - corner, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - corner: Corner; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - const isNesw = corner === "top-right" || corner === "bottom-left"; - - return ( - - ); -} - -function RotationHandle({ - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - - ); -} diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx deleted file mode 100644 index 2ad46497..00000000 --- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function AudioProperties() { - return
Audio properties
; -} diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx deleted file mode 100644 index a1324bdf..00000000 --- a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx +++ /dev/null @@ -1,215 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import type { Effect } from "@/types/effects"; -import type { VisualElement } from "@/types/timeline"; -import { getEffect } from "@/lib/effects/registry"; -import { useEditor } from "@/hooks/use-editor"; -import { usePropertiesStore } from "@/stores/properties-store"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, - SectionFields, -} from "./section"; -import { EffectParamField } from "./effect-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowLeft01Icon, - Delete02Icon, - ViewIcon, - ViewOffSlashIcon, -} from "@hugeicons/core-free-icons"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { cn } from "@/utils/ui"; -export function ClipEffectsProperties({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const closeClipEffects = usePropertiesStore( - (state) => state.closeClipEffects, - ); - const editor = useEditor(); - const effects = element.effects ?? []; - - useEffect(() => { - if (effects.length === 0) closeClipEffects(); - }, [effects.length, closeClipEffects]); - - const [dragIndex, setDragIndex] = useState(null); - const [dropIndex, setDropIndex] = useState(null); - - const handleDragStart = ({ index }: { index: number }) => { - setDragIndex(index); - }; - - const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => { - event.preventDefault(); - if (index !== dropIndex) setDropIndex(index); - }; - - const handleDrop = ({ toIndex }: { toIndex: number }) => { - if (dragIndex !== null && dragIndex !== toIndex) { - editor.timeline.reorderClipEffects({ - trackId, - elementId: element.id, - fromIndex: dragIndex, - toIndex, - }); - } - setDragIndex(null); - setDropIndex(null); - }; - - const handleDragEnd = () => { - setDragIndex(null); - setDropIndex(null); - }; - - return ( -
-
- - Effects -
- - {effects.map((effect, index) => ( - // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder -
handleDragStart({ index })} - onDragOver={(event) => handleDragOver({ event, index })} - onDrop={() => handleDrop({ toIndex: index })} - onDragEnd={handleDragEnd} - className={cn( - "group", - dragIndex === index && "opacity-40", - dropIndex === index && - dragIndex !== null && - dragIndex !== index && - (index < dragIndex - ? "border-t-2 border-primary" - : "border-b-2 border-primary"), - )} - > - -
- ))} -
-
- ); -} - -function ClipEffectSection({ - effect, - element, - trackId, -}: { - effect: Effect; - element: VisualElement; - trackId: string; -}) { - const editor = useEditor(); - const definition = getEffect({ effectType: effect.type }); - - const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => { - const updatedEffects = (element.effects ?? []).map((existing) => - existing.id !== effect.id - ? existing - : { ...existing, params: { ...existing.params, [key]: value } }, - ); - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { effects: updatedEffects }, - }, - ], - }); - }; - - const commitParam = () => editor.timeline.commitPreview(); - - const toggleEffect = () => - editor.timeline.toggleClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }); - - const removeEffect = () => - editor.timeline.removeClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }); - - return ( -
- - - -
- } - > - - {definition.name} - - - {effect.enabled && ( - - - {definition.params.map((param) => ( - - ))} - - - )} - - ); -} diff --git a/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx new file mode 100644 index 00000000..b5b8685f --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx @@ -0,0 +1,32 @@ +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { KeyframeIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +export function KeyframeToggle({ + isActive, + isDisabled = false, + title, + onToggle, +}: { + isActive: boolean; + isDisabled?: boolean; + title: string; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/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 new file mode 100644 index 00000000..e69c6918 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx @@ -0,0 +1,194 @@ +"use client"; + +import type { ParamDefinition, NumberParamDefinition } from "@/lib/params"; +import { formatNumberForDisplay, snapToStep } from "@/utils/math"; +import { SectionField } from "@/components/section"; +import { NumberField } from "@/components/ui/number-field"; +import { Switch } from "@/components/ui/switch"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { KeyframeToggle } from "./keyframe-toggle"; + +export function PropertyParamField({ + param, + value, + onPreview, + onCommit, + keyframe, +}: { + param: ParamDefinition; + value: number | string | boolean; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; + keyframe?: { + isActive: boolean; + isDisabled: boolean; + onToggle: () => void; + }; +}) { + return ( + + ) : undefined + } + > + + + ); +} + +function ParamInput({ + param, + value, + onPreview, + onCommit, +}: { + param: ParamDefinition; + value: number | string | boolean; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; +}) { + if (param.type === "number") { + return ( + + ); + } + + if (param.type === "boolean") { + return ( + { + onPreview(checked); + onCommit(); + }} + /> + ); + } + + if (param.type === "select") { + return ( + + ); + } + + if (param.type === "color") { + return ( + onPreview(`#${color}`)} + onChangeEnd={(color) => { + onPreview(`#${color}`); + onCommit(); + }} + /> + ); + } + + return null; +} + +function NumberParamField({ + param, + value, + onPreview, + onCommit, +}: { + param: NumberParamDefinition; + value: number; + onPreview: (value: number) => void; + onCommit: () => void; +}) { + const { min, max, step, displayMultiplier = 1 } = param; + const displayValue = value * displayMultiplier; + const clampDisplayValue = (nextDisplayValue: number) => + Math.max( + min, + max !== undefined ? Math.min(max, nextDisplayValue) : nextDisplayValue, + ); + + const previewFromDisplay = (displayVal: number) => { + const clamped = clampDisplayValue( + snapToStep({ value: displayVal, step }), + ); + onPreview(clamped / displayMultiplier); + }; + + const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step)); + + const draft = usePropertyDraft({ + displayValue: formatNumberForDisplay({ + value: displayValue, + maxFractionDigits, + }), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clampDisplayValue(snapToStep({ value: parsed, step })); + }, + onPreview: previewFromDisplay, + onCommit, + }); + + const handleReset = () => { + onPreview(param.default); + onCommit(); + }; + + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx deleted file mode 100644 index 408d430a..00000000 --- a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects"; -import { clamp } from "@/utils/math"; -import { SectionField } from "./section"; -import { Slider } from "@/components/ui/slider"; -import { NumberField } from "@/components/ui/number-field"; -import { Switch } from "@/components/ui/switch"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { usePropertyDraft } from "./hooks/use-property-draft"; - -export function EffectParamField({ - param, - value, - onPreview, - onCommit, -}: { - param: EffectParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; -}) { - return ( - - - - ); -} - -function EffectParamInput({ - param, - value, - onPreview, - onCommit, -}: { - param: EffectParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; -}) { - if (param.type === "number") { - return ( - - ); - } - - if (param.type === "boolean") { - return ( - { - onPreview(checked); - onCommit(); - }} - /> - ); - } - - if (param.type === "select") { - return ( - - ); - } - - if (param.type === "color") { - return ( - onPreview(event.target.value)} - onBlur={onCommit} - /> - ); - } - - return null; -} - -function NumberParamField({ - param, - value, - onPreview, - onCommit, -}: { - param: NumberEffectParamDefinition; - value: number; - onPreview: (value: number) => void; - onCommit: () => void; -}) { - const { min, max, step } = param; - - const draft = usePropertyDraft({ - displayValue: String(value), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min, max }); - }, - onPreview, - onCommit, - }); - - return ( -
- onPreview(newValue)} - onValueCommit={onCommit} - /> - -
- ); -} diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx deleted file mode 100644 index 75fc30a1..00000000 --- a/apps/web/src/components/editor/panels/properties/effect-properties.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import type { EffectElement } from "@/types/timeline"; -import { getEffect } from "@/lib/effects/registry"; -import { useEditor } from "@/hooks/use-editor"; -import { - Section, - SectionContent, - SectionHeader, - SectionFields, - SectionTitle, -} from "./section"; -import { EffectParamField } from "./effect-param-field"; - -export function EffectProperties({ - element, - trackId, -}: { - element: EffectElement; - trackId: string; -}) { - const editor = useEditor(); - const definition = getEffect({ effectType: element.effectType }); - - const previewParam = - ({ key }: { key: string }) => - (value: number | string | boolean) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { params: { ...element.params, [key]: value } }, - }, - ], - }); - - return ( -
- - {definition.name} - - - - {definition.params.map((param) => ( - editor.timeline.commitPreview()} - /> - ))} - - -
- ); -} diff --git a/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 dbd19e4c..957ee1c9 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 @@ -4,8 +4,8 @@ import { hasKeyframesForPath, upsertElementKeyframe, } from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; -import type { TimelineElement } from "@/types/timeline"; +import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/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 11aa049c..0f0168f8 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 @@ -4,8 +4,9 @@ import { hasKeyframesForPath, upsertElementKeyframe, } from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; -import type { TimelineElement } from "@/types/timeline"; +import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; +import { snapToStep } from "@/utils/math"; import { usePropertyDraft } from "./use-property-draft"; export function useKeyframedNumberProperty({ @@ -18,6 +19,7 @@ export function useKeyframedNumberProperty({ displayValue, parse, valueAtPlayhead, + step, buildBaseUpdates, }: { trackId: string; @@ -29,9 +31,12 @@ export function useKeyframedNumberProperty({ displayValue: string; parse: (input: string) => number | null; valueAtPlayhead: number; + step?: number; buildBaseUpdates: ({ value }: { value: number }) => Partial; }) { const editor = useEditor(); + const snapValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); const keyframeAtTime = isPlayheadWithinElementRange @@ -43,6 +48,7 @@ export function useKeyframedNumberProperty({ hasAnimatedKeyframes && isPlayheadWithinElementRange; const previewValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); if (shouldUseAnimatedChannel) { editor.timeline.previewElements({ updates: [ @@ -54,7 +60,7 @@ export function useKeyframedNumberProperty({ animations, propertyPath, time: localTime, - value, + value: nextValue, }), }, }, @@ -68,7 +74,7 @@ export function useKeyframedNumberProperty({ { trackId, elementId, - updates: buildBaseUpdates({ value }), + updates: buildBaseUpdates({ value: nextValue }), }, ], }); @@ -76,7 +82,10 @@ export function useKeyframedNumberProperty({ const propertyDraft = usePropertyDraft({ displayValue, - parse, + parse: (input) => { + const parsedValue = parse(input); + return parsedValue === null ? null : snapValue(parsedValue); + }, onPreview: (value) => previewValue({ value }), onCommit: () => editor.timeline.commitPreview(), }); @@ -107,13 +116,14 @@ export function useKeyframedNumberProperty({ elementId, propertyPath, time: localTime, - value: valueAtPlayhead, + value: snapValue(valueAtPlayhead), }, ], }); }; const commitValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); if (shouldUseAnimatedChannel) { editor.timeline.upsertKeyframes({ keyframes: [ @@ -122,7 +132,7 @@ export function useKeyframedNumberProperty({ elementId, propertyPath, time: localTime, - value, + value: nextValue, }, ], }); @@ -134,7 +144,7 @@ export function useKeyframedNumberProperty({ { trackId, elementId, - updates: buildBaseUpdates({ value }), + updates: buildBaseUpdates({ value: nextValue }), }, ], }); 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 new file mode 100644 index 00000000..335e3fb6 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -0,0 +1,148 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { + buildGraphicParamPath, + getKeyframeAtTime, + getParamDefaultInterpolation, + getParamValueKind, + hasKeyframesForPath, + upsertPathKeyframe, +} from "@/lib/animation"; +import type { + ElementAnimations, +} from "@/lib/animation/types"; +import type { ParamDefinition } from "@/lib/params"; +import type { TimelineElement } from "@/lib/timeline"; + +export interface KeyframedParamPropertyResult { + hasAnimatedKeyframes: boolean; + isKeyframedAtTime: boolean; + keyframeIdAtTime: string | null; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; + toggleKeyframe: () => void; +} + +export function useKeyframedParamProperty({ + param, + trackId, + elementId, + animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates, +}: { + param: ParamDefinition; + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: number | string | boolean; + buildBaseUpdates: ({ + value, + }: { + value: number | string | boolean; + }) => Partial; +}): KeyframedParamPropertyResult { + const editor = useEditor(); + const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ + animations, + propertyPath, + time: localTime, + }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertPathKeyframe({ + animations, + propertyPath, + time: localTime, + value, + valueKind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ + param, + }), + numericRange: + param.type === "number" + ? { min: param.min, max: param.max, step: param.step } + : undefined, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + return { + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + onPreview: previewValue, + onCommit: () => editor.timeline.commitPreview(), + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts new file mode 100644 index 00000000..188f05cb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts @@ -0,0 +1,190 @@ +import { useEditor } from "@/hooks/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/lib/animation"; +import type { + AnimationPropertyPath, + ElementAnimations, + VectorValue, +} from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; +import { snapToStep } from "@/utils/math"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedVectorProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + displayX, + displayY, + parseComponent, + step, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: VectorValue; + displayX: string; + displayY: string; + parseComponent: (input: string) => number | null; + step?: number; + buildBaseUpdates: ({ + value, + }: { + value: VectorValue; + }) => Partial; +}) { + const editor = useEditor(); + const snapComponentValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; + + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewVector = ({ value }: { value: VectorValue }) => { + const nextValue = { + x: snapComponentValue(value.x), + y: snapComponentValue(value.y), + }; + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: nextValue, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }], + }); + }; + + const x = usePropertyDraft({ + displayValue: displayX, + parse: (input) => { + const parsedValue = parseComponent(input); + return parsedValue === null ? null : snapComponentValue(parsedValue); + }, + onPreview: (xVal) => + previewVector({ value: { x: xVal, y: resolvedValue.y } }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const y = usePropertyDraft({ + displayValue: displayY, + parse: (input) => { + const parsedValue = parseComponent(input); + return parsedValue === null ? null : snapComponentValue(parsedValue); + }, + onPreview: (yVal) => + previewVector({ value: { x: resolvedValue.x, y: yVal } }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) return; + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + const commitX = ({ value }: { value: number }) => { + const vector: VectorValue = { + x: snapComponentValue(value), + y: snapComponentValue(resolvedValue.y), + }; + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: vector }, + ], + }); + return; + } + editor.timeline.updateElements({ + updates: [ + { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + ], + }); + }; + + const commitY = ({ value }: { value: number }) => { + const vector: VectorValue = { + x: snapComponentValue(resolvedValue.x), + y: snapComponentValue(value), + }; + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: vector }, + ], + }); + return; + } + editor.timeline.updateElements({ + updates: [ + { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + ], + }); + }; + + return { + x, + y, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitX, + commitY, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index cb45996d..3f3ff1f3 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -1,86 +1,95 @@ "use client"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { AudioProperties } from "./audio-properties"; -import { VideoProperties } from "./video-properties"; -import { TextProperties } from "./text-properties"; -import { EffectProperties } from "./effect-properties"; -import { ClipEffectsProperties } from "./clip-effects-properties"; -import { EmptyView } from "./empty-view"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { usePropertiesStore } from "@/stores/properties-store"; -import { isVisualElement } from "@/lib/timeline"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; - -function ElementProperties({ - track, - element, -}: { - track: TimelineTrack; - element: TimelineElement; -}) { - if (element.type === "text") { - return ; - } - if (element.type === "audio") { - return ; - } - if ( - element.type === "video" || - element.type === "image" || - element.type === "sticker" - ) { - return ; - } - if (element.type === "effect") { - return ; - } - return null; -} +import { usePropertiesStore } from "./stores/properties-store"; +import { getPropertiesConfig } from "./registry"; +import { cn } from "@/utils/ui"; +import { EmptyView } from "./empty-view"; export function PropertiesPanel() { const editor = useEditor(); + useEditor((e) => e.timeline.getTracks()); + useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); - const clipEffectsTarget = usePropertiesStore( - (state) => state.clipEffectsTarget, - ); + const { activeTabPerType, setActiveTab } = usePropertiesStore(); - const clipEffectsTrack = clipEffectsTarget - ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId }) - : null; - const clipEffectsElement = clipEffectsTrack?.elements.find( - (element) => element.id === clipEffectsTarget?.elementId, - ); - const isShowingClipEffects = - clipEffectsTrack && - clipEffectsElement && - isVisualElement(clipEffectsElement); + if (selectedElements.length === 0) { + return ( +
+ +
+ ); + } + + if (selectedElements.length > 1) { + return ( +
+

+ {selectedElements.length} elements selected.0 +

+
+ ); + } + + const mediaAssets = editor.media.getAssets(); const elementsWithTracks = editor.timeline.getElementsWithTracks({ elements: selectedElements, }); + const elementWithTrack = elementsWithTracks[0]; + + if (!elementWithTrack) return null; + + const { element, track } = elementWithTrack; + const config = getPropertiesConfig({ element, mediaAssets }); + const visibleTabs = config.tabs; + + const storedTabId = activeTabPerType[element.type]; + const isStoredTabVisible = visibleTabs.some((t) => t.id === storedTabId); + const activeTabId = isStoredTabVisible ? storedTabId : config.defaultTab; + const activeTab = + visibleTabs.find((t) => t.id === activeTabId) ?? visibleTabs[0]; + + if (!activeTab) return null; return ( -
- {isShowingClipEffects ? ( - - ) : selectedElements.length > 0 ? ( - - {elementsWithTracks.map(({ track, element }) => ( - +
+ +
+ {visibleTabs.map((tab) => ( + + + + + {tab.label} + ))} - - ) : ( - - )} +
+
+ + {activeTab.content({ trackId: track.id })} +
); } diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx deleted file mode 100644 index b5b8685f..00000000 --- a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { KeyframeIcon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -export function KeyframeToggle({ - isActive, - isDisabled = false, - title, - onToggle, -}: { - isActive: boolean; - isDisabled?: boolean; - title: string; - onToggle: () => void; -}) { - return ( - - ); -} diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx new file mode 100644 index 00000000..706faef4 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -0,0 +1,303 @@ +import type { ReactNode } from "react"; +import type { + EffectElement, + GraphicElement, + ImageElement, + MaskableElement, + RetimableElement, + StickerElement, + TextElement, + VisualElement, + VideoElement, + AudioElement, + TimelineElement, +} from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + TextFontIcon, + ArrowExpandIcon, + RainDropIcon, + MusicNote03Icon, + 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 { OcShapesIcon } from "@opencut/ui/icons"; + +export type TabContentProps = { + trackId: string; +}; + +export type PropertiesTabDef = { + id: string; + label: string; + icon: ReactNode; + content: (props: TabContentProps) => ReactNode; +}; + +export type ElementPropertiesConfig = { + defaultTab: string; + tabs: PropertiesTabDef[]; +}; + +function buildTransformTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "transform", + label: "Transform", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function buildBlendingTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "blending", + label: "Blending", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function buildAudioTab({ + element, +}: { + element: AudioElement | VideoElement; +}): PropertiesTabDef { + return { + id: "audio", + label: "Audio", + icon: , + content: ({ trackId }) => , + }; +} + +function buildSpeedTab({ + element, +}: { + element: RetimableElement; +}): PropertiesTabDef { + return { + id: "speed", + label: "Speed", + icon: , + content: ({ trackId }) => , + }; +} + +function buildMasksTab({ + element, +}: { + element: MaskableElement; +}): PropertiesTabDef { + return { + id: "masks", + label: "Masks", + icon: , + content: ({ trackId }) => , + }; +} + +function buildClipEffectsTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { + return { + id: "text", + label: "Text", + icon: , + content: ({ trackId }) => , + }; +} + +function buildGraphicTab({ + element, +}: { + element: GraphicElement; +}): PropertiesTabDef { + return { + id: "graphic", + label: "Graphic", + icon: , + content: ({ trackId }) => , + }; +} + +function buildStandaloneEffectTab({ + element, +}: { + element: EffectElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function getTextConfig({ + element, +}: { + element: TextElement; +}): ElementPropertiesConfig { + return { + defaultTab: "text", + tabs: [ + buildTextTab({ element }), + buildTransformTab({ element }), + buildBlendingTab({ element }), + ], + }; +} + +function getVideoConfig({ + element, + mediaAsset, +}: { + element: VideoElement; + mediaAsset: MediaAsset | undefined; +}): ElementPropertiesConfig { + const showAudioTab = mediaAsset?.hasAudio !== false; + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + ...(showAudioTab ? [buildAudioTab({ element })] : []), + buildSpeedTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getImageConfig({ + element, +}: { + element: ImageElement; +}): ElementPropertiesConfig { + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getStickerConfig({ + element, +}: { + element: StickerElement; +}): ElementPropertiesConfig { + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getGraphicConfig({ + element, +}: { + element: GraphicElement; +}): ElementPropertiesConfig { + return { + defaultTab: "graphic", + tabs: [ + buildGraphicTab({ element }), + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getAudioConfig({ + element, +}: { + element: AudioElement; +}): ElementPropertiesConfig { + return { + defaultTab: "audio", + tabs: [buildAudioTab({ element }), buildSpeedTab({ element })], + }; +} + +function getEffectConfig({ + element, +}: { + element: EffectElement; +}): ElementPropertiesConfig { + return { + defaultTab: "effects", + tabs: [buildStandaloneEffectTab({ element })], + }; +} + +export function getPropertiesConfig({ + element, + mediaAssets, +}: { + element: TimelineElement; + mediaAssets: MediaAsset[]; +}): ElementPropertiesConfig { + switch (element.type) { + case "text": + return getTextConfig({ element }); + case "video": { + const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId); + return getVideoConfig({ element, mediaAsset }); + } + case "image": + return getImageConfig({ element }); + case "sticker": + return getStickerConfig({ element }); + case "graphic": + return getGraphicConfig({ element }); + case "audio": + return getAudioConfig({ element }); + case "effect": + return getEffectConfig({ element }); + } +} diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx deleted file mode 100644 index 5989108b..00000000 --- a/apps/web/src/components/editor/panels/properties/section.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { createContext, useContext, useEffect, useState } from "react"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowDownIcon } from "@hugeicons/core-free-icons"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; - -const sectionExpandedCache = new Map(); -const mountedSectionKeys = new Set(); - -interface SectionContext { - isOpen: boolean; - toggle: () => void; - collapsible: boolean; -} - -const SectionCtx = createContext(null); - -function useSectionContext() { - return useContext(SectionCtx); -} - -interface SectionProps { - children: React.ReactNode; - collapsible?: boolean; - defaultOpen?: boolean; - sectionKey?: string; - className?: string; - showTopBorder?: boolean; - showBottomBorder?: boolean; -} - -export function Section({ - children, - collapsible = false, - defaultOpen = true, - sectionKey, - className, - showTopBorder = true, - showBottomBorder = true, -}: SectionProps) { - const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; - const [isOpen, setIsOpen] = useState(cached ?? defaultOpen); - - useEffect(() => { - if (!sectionKey) return; - if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) { - console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`); - } - mountedSectionKeys.add(sectionKey); - return () => { mountedSectionKeys.delete(sectionKey); }; - }, [sectionKey]); - - const toggle = () => { - const next = !isOpen; - setIsOpen(next); - if (sectionKey) sectionExpandedCache.set(sectionKey, next); - }; - - return ( - -
- {children} -
-
- ); -} - -interface SectionHeaderProps { - children?: React.ReactNode; - trailing?: React.ReactNode; - leading?: React.ReactNode; - actions?: React.ReactNode; - onClick?: () => void; - className?: string; -} - -export function SectionHeader({ - children, - trailing, - leading, - actions, - onClick, - className, -}: SectionHeaderProps) { - const ctx = useSectionContext(); - const isCollapsible = ctx?.collapsible ?? false; - const isOpen = ctx?.isOpen ?? true; - const isInteractive = isCollapsible || !!onClick; - const handleClick = isCollapsible ? ctx?.toggle : onClick; - - const chevronIcon = isCollapsible ? ( - - ) : null; - - const headerContent = ( - <> - {leading} -
{children}
- {(trailing || chevronIcon) && ( -
- {trailing} - {chevronIcon && ( - - )} -
- )} - {actions} - - ); - - if (!isInteractive) { - return ( -
- {headerContent} -
- ); - } - - return ( - // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested - ); - } - - return ( - - {children} - - ); -} - -export function SectionFields({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( -
{children}
- ); -} - -export function SectionField({ - label, - beforeLabel, - children, - className, -}: { - label: string; - beforeLabel?: React.ReactNode; - children: React.ReactNode; - className?: string; -}) { - return ( -
-
- {beforeLabel} - -
- {children} -
- ); -} - -export function SectionContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - const ctx = useSectionContext(); - const isCollapsible = ctx?.collapsible ?? false; - const isOpen = ctx?.isOpen ?? true; - - if (isCollapsible) { - return ( -
-
-
{children}
-
-
- ); - } - - return
{children}
; -} diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx deleted file mode 100644 index a50e84e7..00000000 --- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useEditor } from "@/hooks/use-editor"; -import { clamp } from "@/utils/math"; -import { NumberField } from "@/components/ui/number-field"; -import { - DEFAULT_BLEND_MODE, - DEFAULT_OPACITY, -} from "@/constants/timeline-constants"; -import { OcCheckerboardIcon } from "@opencut/ui/icons"; -import { Fragment, useRef } from "react"; -import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section"; -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { BlendMode } from "@/types/rendering"; -import type { ElementType } from "@/types/timeline"; -import type { ElementAnimations } from "@/types/animation"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { RainDropIcon } from "@hugeicons/core-free-icons"; -import { KeyframeToggle } from "../keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/lib/animation"; -import { isPropertyAtDefault } from "./transform"; - -type BlendingElement = { - id: string; - opacity: number; - type: ElementType; - blendMode?: BlendMode; - startTime: number; - duration: number; - animations?: ElementAnimations; -}; - -const BLEND_MODE_GROUPS = [ - [{ 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 BlendingSection({ - element, - trackId, -}: { - element: BlendingElement; - trackId: string; -}) { - const editor = useEditor(); - const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE; - const didSelectRef = useRef(false); - const committedBlendModeRef = useRef(blendMode); - if (!editor.timeline.isPreviewActive()) { - committedBlendModeRef.current = blendMode; - } - - const previewBlendMode = ({ value }: { value: BlendMode }) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { blendMode: value } }, - ], - }); - - const commitBlendMode = (value: string) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { blendMode: value as BlendMode }, - }, - ], - }); - } - didSelectRef.current = true; - }; - - const handleBlendModeOpenChange = (isOpen: boolean) => { - if (!isOpen) { - if (!didSelectRef.current) editor.timeline.discardPreview(); - didSelectRef.current = false; - } - }; - - 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, - buildBaseUpdates: ({ value }) => ({ opacity: value }), - }); - - return ( -
- Blending - -
- - } - > - - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })} - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOpacity, - staticValue: element.opacity, - defaultValue: DEFAULT_OPACITY, - })} - dragSensitivity="slow" - /> - - - - -
-
-
- ); -} diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx deleted file mode 100644 index 8be58f39..00000000 --- a/apps/web/src/components/editor/panels/properties/sections/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./transform"; -export * from "./blending"; diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx deleted file mode 100644 index 6aaaa1fe..00000000 --- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import { NumberField } from "@/components/ui/number-field"; -import { useEditor } from "@/hooks/use-editor"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { AnimationPropertyPath } from "@/types/animation"; -import type { VisualElement } from "@/types/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "../section"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowExpandIcon, - Link05Icon, - RotateClockwiseIcon, -} from "@hugeicons/core-free-icons"; -import { useState } from "react"; -import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; -import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation"; -import { KeyframeToggle } from "../keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; - -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 TransformSection({ - element, - trackId, - showTopBorder = true, -}: { - element: VisualElement; - trackId: string; - showTopBorder?: boolean; -}) { - const editor = useEditor(); - const [isScaleLocked, setIsScaleLocked] = useState(false); - const playheadTime = editor.playback.getCurrentTime(); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - const isPlayheadWithinElementRange = - playheadTime >= element.startTime - TIME_EPSILON_SECONDS && - playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS; - - const positionX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.position.x", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.x).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.x, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { - ...element.transform.position, - x: value, - }, - }, - }), - }); - - const positionY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.position.y", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.y).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.y, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { - ...element.transform.position, - y: value, - }, - }, - }), - }); - - const scale = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scale", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scale * 100).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return Math.max(parsed, 1) / 100; - }, - valueAtPlayhead: resolvedTransform.scale, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scale: value, - }, - }), - }); - const scaleFieldProps = { - className: "flex-1", - value: scale.displayValue, - onFocus: scale.onFocus, - onChange: scale.onChange, - onBlur: scale.onBlur, - dragSensitivity: "slow" as const, - onScrub: scale.scrubTo, - onScrubEnd: scale.commitScrub, - onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scale.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scale, - staticValue: element.transform.scale, - defaultValue: DEFAULT_TRANSFORM.scale, - }), - }; - - 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, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - rotate: value, - }, - }), - }); - - const hasPositionKeyframe = - positionX.isKeyframedAtTime || positionY.isKeyframedAtTime; - - const togglePositionKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) { - const keyframesToRemove: Array<{ - trackId: string; - elementId: string; - propertyPath: AnimationPropertyPath; - keyframeId: string; - }> = []; - if (positionX.keyframeIdAtTime) { - keyframesToRemove.push({ - trackId, - elementId: element.id, - propertyPath: "transform.position.x" as const, - keyframeId: positionX.keyframeIdAtTime, - }); - } - if (positionY.keyframeIdAtTime) { - keyframesToRemove.push({ - trackId, - elementId: element.id, - propertyPath: "transform.position.y" as const, - keyframeId: positionY.keyframeIdAtTime, - }); - } - - editor.timeline.removeKeyframes({ - keyframes: keyframesToRemove, - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId: element.id, - propertyPath: "transform.position.x", - time: localTime, - value: resolvedTransform.position.x, - }, - { - trackId, - elementId: element.id, - propertyPath: "transform.position.y", - time: localTime, - value: resolvedTransform.position.y, - }, - ], - }); - }; - - return ( -
- Transform - - - - } - > -
- {isScaleLocked ? ( - <> - - - - ) : ( - } - {...scaleFieldProps} - className="flex-1" - /> - )} - -
-
- - } - > -
- - positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.x, - staticValue: element.transform.position.x, - defaultValue: DEFAULT_TRANSFORM.position.x, - })} - /> - - positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.y, - staticValue: element.transform.position.y, - defaultValue: DEFAULT_TRANSFORM.position.y, - })} - /> -
-
- - - } - > -
- } - 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: DEFAULT_TRANSFORM.rotate }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.rotate, - staticValue: element.transform.rotate, - defaultValue: DEFAULT_TRANSFORM.rotate, - })} - /> -
-
-
-
-
- ); -} diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts new file mode 100644 index 00000000..1783e3fc --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; + +interface PropertiesState { + activeTabPerType: Record; + setActiveTab: (elementType: string, tabId: string) => void; + isTransformScaleLocked: boolean; + setTransformScaleLocked: (locked: boolean) => void; +} + +export const usePropertiesStore = create()((set) => ({ + activeTabPerType: {}, + setActiveTab: (elementType, tabId) => + set((state) => ({ + activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId }, + })), + isTransformScaleLocked: false, + setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }), +})); 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 new file mode 100644 index 00000000..41b6ec6d --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx @@ -0,0 +1,109 @@ +import { NumberField } from "@/components/ui/number-field"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import type { AudioElement, VideoElement } from "@/lib/timeline"; +import { resolveNumberAtTime } from "@/lib/animation"; +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"; + +export function AudioTab({ + element, + trackId, +}: { + element: AudioElement | VideoElement; + trackId: string; +}) { + 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: resolvedVolume.toFixed(1), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) { + return null; + } + + return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }); + }, + valueAtPlayhead: resolvedVolume, + step: 0.1, + buildBaseUpdates: ({ value }) => ({ + volume: value, + }), + }); + const isDefault = + volume.hasAnimatedKeyframes && isPlayheadWithinElementRange + ? isNearlyEqual({ + leftValue: resolvedVolume, + rightValue: DEFAULTS.element.volume, + }) + : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; + + return ( +
+ + Audio + + + + + } + > + } + value={volume.displayValue} + onFocus={volume.onFocus} + onChange={volume.onChange} + onBlur={volume.onBlur} + dragSensitivity="slow" + scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} + onScrub={volume.scrubTo} + onScrubEnd={volume.commitScrub} + onReset={() => + volume.commitValue({ + value: DEFAULTS.element.volume, + }) + } + isDefault={isDefault} + suffix="dB" + /> + + + +
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx new file mode 100644 index 00000000..c360817c --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx @@ -0,0 +1,230 @@ +import { useEditor } from "@/hooks/use-editor"; +import { clamp } from "@/utils/math"; +import { NumberField } from "@/components/ui/number-field"; +import { OcCheckerboardIcon } from "@opencut/ui/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: "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: string) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { blendMode: value as BlendMode }, + }, + ], + }); + } + markCommitted(); + }; + + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedOpacity = resolveOpacityAtTime({ + baseOpacity: element.opacity, + animations: element.animations, + localTime, + }); + + const opacity = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "opacity", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOpacity * 100).toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: 0, max: 100 }) / 100; + }, + valueAtPlayhead: resolvedOpacity, + step: 0.01, + buildBaseUpdates: ({ value }) => ({ opacity: value }), + }); + + return ( +
+ + Blending + + +
+ + } + > + + } + value={opacity.displayValue} + min={0} + max={100} + onFocus={opacity.onFocus} + onChange={opacity.onChange} + onBlur={opacity.onBlur} + onScrub={opacity.scrubTo} + onScrubEnd={opacity.commitScrub} + onReset={() => + opacity.commitValue({ value: DEFAULTS.element.opacity }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOpacity, + staticValue: element.opacity, + defaultValue: DEFAULTS.element.opacity, + })} + dragSensitivity="slow" + /> + + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx new file mode 100644 index 00000000..1b94ee2d --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useState } from "react"; +import type { ParamValues } from "@/lib/params"; +import type { Effect } from "@/lib/effects/types"; +import type { EffectElement, VisualElement } from "@/lib/timeline"; +import { effectsRegistry } from "@/lib/effects"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementPreview } from "@/hooks/use-element-preview"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, + SectionFields, +} from "@/components/section"; +import { PropertyParamField } from "../components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Delete02Icon, + ViewIcon, + ViewOffSlashIcon, + MagicWand05Icon, +} from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; +import { Separator } from "@/components/ui/separator"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; + +export function StandaloneEffectTab({ + element, + trackId, +}: { + element: EffectElement; + trackId: string; +}) { + const { renderElement, previewUpdates, commit } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const effect: Effect = { + id: element.id, + type: element.effectType, + params: element.params, + enabled: true, + }; + + const previewParam = (key: string) => (value: number | string | boolean) => { + previewUpdates({ + params: { ...(renderElement as EffectElement).params, [key]: value }, + }); + }; + + return ( +
+
+ Effect +
+ +
+ ); +} + +export function ClipEffectsTab({ + element, + trackId, +}: { + element: VisualElement; + trackId: string; +}) { + const [dragIndex, setDragIndex] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + const editor = useEditor(); + const { renderElement, previewUpdates, commit } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const effects: Effect[] = element.effects ?? []; + + const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => { + return ( + (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId) + ?.params ?? + effects.find((ef) => ef.id === effectId)?.params ?? + {} + ); + }; + + const buildPreviewParam = + (effectId: string) => + (key: string) => + (value: number | string | boolean) => { + const updatedEffects = ( + (renderElement as VisualElement).effects ?? [] + ).map((existing) => + existing.id !== effectId + ? existing + : { ...existing, params: { ...existing.params, [key]: value } }, + ); + previewUpdates({ effects: updatedEffects }); + }; + + const handleDragStart = ({ index }: { index: number }) => setDragIndex(index); + + const handleDragOver = ({ + event, + index, + }: { + event: React.DragEvent; + index: number; + }) => { + event.preventDefault(); + if (index !== dropIndex) setDropIndex(index); + }; + + const handleDrop = ({ toIndex }: { toIndex: number }) => { + if (dragIndex !== null && dragIndex !== toIndex) { + editor.timeline.reorderClipEffects({ + trackId, + elementId: element.id, + fromIndex: dragIndex, + toIndex, + }); + } + setDragIndex(null); + setDropIndex(null); + }; + + const handleDragEnd = () => { + setDragIndex(null); + setDropIndex(null); + }; + + return ( +
+
+ Effects +
+ {effects.length === 0 ? ( + + ) : ( +
    + {effects.map((effect, index) => { + const resolvedDragIndex = dragIndex ?? -1; + const isDragging = dragIndex === index; + const isDropTarget = + dropIndex === index && dragIndex !== null && dragIndex !== index; + const showTopDropIndicator = + isDropTarget && index < resolvedDragIndex; + const showBottomDropIndicator = + isDropTarget && index > resolvedDragIndex; + + return ( +
  • handleDragStart({ index })} + onDragOver={(event) => handleDragOver({ event, index })} + onDrop={() => handleDrop({ toIndex: index })} + onDragEnd={handleDragEnd} + className={cn( + "group list-none", + isDragging && "opacity-40", + showTopDropIndicator && "border-t-2 border-primary", + showBottomDropIndicator && "border-b-2 border-primary", + )} + > + + editor.timeline.toggleClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }) + } + onRemove={() => + editor.timeline.removeClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }) + } + /> +
  • + ); + })} +
+ )} +
+ ); +} + +function EmptyView() { + const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab); + + return ( +
+ +
+

No effects

+

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

+
+ +
+ ); +} + +function EffectSection({ + effect, + renderParams, + previewParam, + onCommit, + onToggle, + onRemove, +}: { + effect: Effect; + renderParams: ParamValues; + previewParam: (key: string) => (value: number | string | boolean) => void; + onCommit: () => void; + onToggle?: () => void; + onRemove?: () => void; +}) { + const definition = effectsRegistry.get(effect.type); + + return ( +
+ + + +
+ ) + } + > + + {definition.name} + + + + + {definition.params.map((param) => ( +
+
+ +
+ +
+ ))} +
+
+ + ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx new file mode 100644 index 00000000..22dae8cc --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useRef } from "react"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { + useKeyframedParamProperty, + type KeyframedParamPropertyResult, +} from "../hooks/use-keyframed-param-property"; +import { resolveGraphicParamsAtTime } from "@/lib/animation"; +import type { ParamDefinition, ParamValues } from "@/lib/params"; +import type { GraphicElement } from "@/lib/timeline"; +import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; +import { useElementPreview } from "@/hooks/use-element-preview"; +import { useEditor } from "@/hooks/use-editor"; +import { + Section, + SectionContent, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { PropertyParamField } from "../components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +registerDefaultGraphics(); + +const DEFAULT_STROKE_WIDTH = 2; + +export function GraphicTab({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicParamsAtTime({ + element: liveElement, + localTime, + }); + + const shapeParams = definition.params.filter((p) => p.group !== "stroke"); + const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); + + return ( +
+
+ + {definition.name} + + + + {shapeParams.map((param) => ( + + ))} + + +
+ {hasStrokeParams && ( + + )} +
+ ); +} + +function StrokeSection({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const editor = useEditor(); + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicParamsAtTime({ + element: liveElement, + localTime, + }); + const strokeParams = definition.params.filter((p) => p.group === "stroke"); + const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); + const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; + + const toggleStroke = () => { + if (isStrokeEnabled) { + lastStrokeWidth.current = Number( + element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, + ); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { params: { ...element.params, strokeWidth: 0 } }, + }, + ], + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + params: { + ...element.params, + strokeWidth: lastStrokeWidth.current, + }, + }, + }, + ], + }); + } + }; + + return ( +
+ { + event.stopPropagation(); + toggleStroke(); + }} + > + + + } + > + Stroke + + + + {strokeParams.map((param) => ( + + ))} + + +
+ ); +} + +function AnimatedGraphicParamField({ + param, + trackId, + element, + localTime, + isPlayheadWithinElementRange, + resolvedParams, +}: { + param: ParamDefinition; + trackId: string; + element: GraphicElement; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedParams: ParamValues; +}) { + const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({ + param, + trackId, + elementId: element.id, + animations: element.animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue: resolvedParams[param.key] ?? param.default, + buildBaseUpdates: ({ value }) => ({ + params: { + ...element.params, + [param.key]: value, + }, + }), + }); + + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx new file mode 100644 index 00000000..6a5d0d0b --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx @@ -0,0 +1,660 @@ +"use client"; + +import type { MaskableElement } from "@/lib/timeline"; +import type { Mask, MaskType } 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 { + Delete02Icon, + FeatherIcon, + PlusSignIcon, + RotateClockwiseIcon, +} from "@hugeicons/core-free-icons"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ColorPicker } from "@/components/ui/color-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 { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/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"]>; + +export function MasksTab({ element, trackId }: MasksTabProps) { + const editor = useEditor(); + const { renderElement, previewUpdates, commit } = + useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + const maskDefs = masksRegistry.getAll(); + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + 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 - TIME_EPSILON_SECONDS, + ); + + return ( + getVisibleElementsWithBounds({ + tracks, + currentTime: clampedTime, + canvasSize, + mediaAssets, + }).find( + (item) => item.trackId === trackId && item.elementId === element.id, + )?.bounds ?? null + ); + }, [ + canvasSize, + currentTime, + element.duration, + element.id, + element.startTime, + mediaAssets, + trackId, + tracks, + ]); + + const handleDropdownOpenChange = (open: boolean) => { + if (hasMask && open) return; + setIsDropdownOpen(open); + onOpenChange(open); + }; + + const previewMask = ({ maskType }: { maskType: MaskType }) => { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + masks: [ + buildDefaultMaskInstance({ + maskType, + elementSize: elementBounds + ? { + width: elementBounds.width, + height: elementBounds.height, + } + : undefined, + }), + ], + } as Partial, + }, + ], + }); + }; + + const commitMask = ({ maskType }: { maskType: MaskType }) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + masks: [ + buildDefaultMaskInstance({ + maskType, + elementSize: elementBounds + ? { + width: elementBounds.width, + height: elementBounds.height, + } + : undefined, + }), + ], + } as Partial, + }, + ], + }); + } + markCommitted(); + setIsDropdownOpen(false); + }; + + const previewMaskParam = + ({ index, key }: { index: number; key: string }) => + (value: number | string | boolean) => { + if (!renderMasks[index]) { + return; + } + + const updatedMasks = renderMasks.map((existingMask, maskIndex) => + maskIndex !== index + ? existingMask + : { + ...existingMask, + params: { + ...existingMask.params, + [key]: value, + }, + }, + ); + + previewUpdates({ masks: updatedMasks } as Partial); + }; + + return ( +
+
+ Masks + + {hasMask ? ( + + + + + + + + Only one mask is supported right now. If you need more, + duplicate the clip and apply a different mask to each copy. + + + ) : ( + + + + )} + + {maskDefs.map((definition) => ( + + previewMask({ maskType: definition.type }) + } + onClick={() => commitMask({ maskType: definition.type })} + > + + {definition.name} + + ))} + + +
+ + {masks.length === 0 ? ( + setIsDropdownOpen(true)} /> + ) : ( + masks.map((mask, index) => ( + + previewMaskParam({ index, key: paramKey }) + } + onCommit={commit} + /> + )) + )} +
+ ); +} + +function MaskItem({ + trackId, + elementId, + mask, + previewParam, + onCommit, +}: MaskItemProps) { + const editor = useEditor(); + const definition = masksRegistry.get(mask.type); + + return ( +
+ + + +
+ } + > +
+ + + {definition.name} + +
+ + + + + + ); +} + +function MaskParamsFields({ + mask, + definition, + previewParam, + onCommit, +}: { + mask: Mask; + definition: RegisteredMaskDefinition; + previewParam: PreviewParamHandler; + onCommit: () => void; +}) { + const featherParam = getNumberParamDefinition({ + definition, + key: "feather", + }); + const strokeWidthParam = getNumberParamDefinition({ + definition, + key: "strokeWidth", + }); + const previewNumberParam = (key: string) => (value: number) => + previewParam(key)(value); + const previewStrokeColor = previewParam("strokeColor"); + const strokeAlignParam = definition.params.find( + (param): param is SelectParamDefinition => + param.key === "strokeAlign" && param.type === "select", + ); + + return ( + + {definition.features.hasPosition && + "centerX" in mask.params && + "centerY" in mask.params && ( + +
+ + +
+
+ )} + + {definition.features.sizeMode === "width-height" && + "width" in mask.params && + "height" in mask.params && ( + +
+ + +
+
+ )} + + {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( + + + + )} + + {definition.features.hasRotation && "rotation" in mask.params && ( + + } + param={getNumberParamDefinition({ + definition, + key: "rotation", + })} + value={getMaskNumber({ + params: mask.params, + key: "rotation", + })} + onPreview={previewNumberParam("rotation")} + onCommit={onCommit} + /> + + )} + + + } + param={featherParam} + value={getMaskNumber({ + params: mask.params, + key: "feather", + })} + onPreview={previewNumberParam("feather")} + onCommit={onCommit} + /> + + + +
+
+ + previewStrokeColor(`#${color}`)} + onChangeEnd={(color) => { + previewStrokeColor(`#${color}`); + onCommit(); + }} + /> +
+ {strokeAlignParam ? ( + + ) : null} +
+
+
+ ); +} + +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({ + params, + key, +}: { + params: Mask["params"]; + key: string; +}): 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 = step >= 1 ? 0 : Math.round(-Math.log10(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 ( + + ); +} + +function EmptyView({ onAddMask }: EmptyViewProps) { + return ( +
+ +
+

No masks

+

+ Add a mask to hide or reveal parts of this layer. +

+
+ +
+ ); +} 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 new file mode 100644 index 00000000..44f5dd74 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx @@ -0,0 +1,137 @@ +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 "@/constants/retime-constants"; +import type { AudioElement, VideoElement } from "@/lib/timeline"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { usePropertyDraft } from "../hooks/use-property-draft"; + +function rateToDisplay({ rate }: { rate: number }): string { + return rate.toFixed(2); +} + +function parseSpeedInput({ input }: { input: string }): number | null { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + const rounded = Math.round(parsed * 100) / 100; + return clampRetimeRate({ rate: rounded }); +} + +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 maintainPitchAvailable = canMaintainPitch({ rate }); + const maintainPitch = element.retime?.maintainPitch ?? false; + const pendingRateRef = useRef(rate); + + const applyRetime = ({ + rate: nextRate, + maintainPitch: nextMaintainPitch, + pushHistory = true, + }: { + rate: number; + maintainPitch: boolean; + pushHistory?: boolean; + }) => { + editor.timeline.updateElementRetime({ + trackId, + elementId: element.id, + retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }), + pushHistory, + }); + }; + + const speedDraft = usePropertyDraft({ + displayValue: rateToDisplay({ rate }), + parse: (input) => parseSpeedInput({ input }), + onPreview: (nextRate) => { + pendingRateRef.current = nextRate; + applyRetime({ rate: nextRate, maintainPitch, pushHistory: false }); + }, + onCommit: () => { + applyRetime({ rate: pendingRateRef.current, maintainPitch }); + }, + }); + + return ( +
+ + Speed + + + + + } + 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={() => + applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch }) + } + isDefault={rate === DEFAULT_RETIME_RATE} + /> + +
+ Change pitch + + applyRetime({ rate, maintainPitch: !checked }) + } + /> +
+
+
+
+ ); +} 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 new file mode 100644 index 00000000..f8e2527a --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -0,0 +1,762 @@ +"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 } from "@/utils/math"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_COLOR } from "@/constants/project-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, + MAX_FONT_SIZE, + MIN_FONT_SIZE, +} from "@/constants/text-constants"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { 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 "@opencut/ui/icons"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { cn } from "@/utils/ui"; + +export function TextTab({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + return ( +
+ + + + +
+ ); +} + +function ContentSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + + const content = usePropertyDraft({ + displayValue: element.content, + parse: (input) => input, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { content: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( +
+ + Content + + +