From fca99d6126c31fbb18ed9f1034cee6f940b040e8 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 10 Feb 2026 16:28:21 +0100 Subject: refactor --- .../editor/panels/assets/draggable-item.tsx | 4 +- .../src/components/editor/panels/assets/index.tsx | 2 +- .../src/components/editor/panels/assets/tabbar.tsx | 29 +- .../editor/panels/assets/views/captions.tsx | 9 +- .../editor/panels/assets/views/media.tsx | 6 +- .../editor/panels/assets/views/settings.tsx | 103 +-- .../editor/panels/assets/views/sounds.tsx | 4 +- .../editor/panels/assets/views/stickers.tsx | 2 +- .../components/editor/panels/assets/views/text.tsx | 2 +- .../components/editor/panels/panel-base-view.tsx | 4 +- .../src/components/editor/panels/preview/index.tsx | 141 +++- .../editor/panels/properties/empty-view.tsx | 20 + .../components/editor/panels/properties/index.tsx | 25 +- .../editor/panels/properties/property-item.tsx | 79 +- .../editor/panels/properties/text-properties.tsx | 905 ++++++++++++--------- .../editor/panels/timeline/audio-waveform.tsx | 176 ++++ .../editor/panels/timeline/bookmarks.tsx | 111 +++ .../editor/panels/timeline/drag-line.tsx | 28 + .../components/editor/panels/timeline/index.tsx | 535 ++++++++++++ .../editor/panels/timeline/snap-indicator.tsx | 52 ++ .../editor/panels/timeline/timeline-element.tsx | 566 +++++++++++++ .../editor/panels/timeline/timeline-playhead.tsx | 92 +++ .../editor/panels/timeline/timeline-ruler.tsx | 126 +++ .../editor/panels/timeline/timeline-tick.tsx | 39 + .../editor/panels/timeline/timeline-toolbar.tsx | 285 +++++++ .../editor/panels/timeline/timeline-track.tsx | 112 +++ 26 files changed, 2911 insertions(+), 546 deletions(-) create mode 100644 apps/web/src/components/editor/panels/properties/empty-view.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/audio-waveform.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/bookmarks.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/drag-line.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/index.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/snap-indicator.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-element.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-tick.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-track.tsx (limited to 'apps/web/src/components/editor/panels') diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 10cb1f8c..517fdc92 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -110,7 +110,7 @@ export function DraggableItem({ { diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index 9161cb1d..e7bd3d4f 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -43,7 +43,7 @@ export function AssetsPanel() { }; return ( -
+
{viewMap[activeTab]}
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index 2c950fdc..31842306 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -6,6 +6,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; import { cn } from "@/utils/ui"; import { TAB_KEYS, @@ -15,9 +16,9 @@ import { export function TabBar() { const { activeTab, setActiveTab } = useAssetsPanelStore(); - const scrollRef = useRef(null); const [showTopFade, setShowTopFade] = useState(false); const [showBottomFade, setShowBottomFade] = useState(false); + const scrollRef = useRef(null); const checkScrollPosition = useCallback(() => { const element = scrollRef.current; @@ -48,32 +49,24 @@ export function TabBar() {
{TAB_KEYS.map((tabKey) => { const tab = tabs[tabKey]; return ( - + + ); diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index d93baeff..daebf2c6 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,5 +1,4 @@ import { Button } from "@/components/ui/button"; -import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; import { Select, @@ -21,6 +20,7 @@ import { transcriptionService } from "@/services/transcription/service"; import { decodeAudioToFloat32 } from "@/lib/media/audio"; import { buildCaptionChunks } from "@/lib/transcription/caption"; import { Spinner } from "@/components/ui/spinner"; +import { Label } from "@/components/ui/label"; export function Captions() { const [selectedLanguage, setSelectedLanguage] = @@ -112,12 +112,13 @@ export function Captions() { ref={containerRef} className="flex h-full flex-col justify-between" > - +
+ - +
{error && ( diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx index 76ae0064..59395fd8 100644 --- a/apps/web/src/components/editor/panels/assets/views/media.tsx +++ b/apps/web/src/components/editor/panels/assets/views/media.tsx @@ -202,7 +202,7 @@ export function MediaView() { className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} {...dragProps} > -
+
Assets
@@ -320,10 +320,10 @@ export function MediaView() { onClick={openFilePicker} disabled={isProcessing} size="sm" - className="items-center justify-center gap-1.5 ml-1.5" + className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3" > - Upload + Import
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx index b8c54510..26afe566 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -21,7 +21,6 @@ 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 type { TProject } from "@/types/project"; import { dimensionToAspectRatio } from "@/utils/geometry"; import { cn } from "@/utils/ui"; import { @@ -30,8 +29,7 @@ import { PropertyItemLabel, PropertyItemValue, } from "@/components/editor/panels/properties/property-item"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { DropperIcon } from "@hugeicons/core-free-icons"; +import { ColorPicker } from "@/components/ui/color-picker"; export function SettingsView() { return ; @@ -56,24 +54,9 @@ function ProjectSettingsTabs() { label: "Background", content: (
-
+
- {/*
- - -
*/} - - {/* another ui */} - {/*
- -
*/}
), }, @@ -83,15 +66,6 @@ function ProjectSettingsTabs() { ); } -function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) { - const { canvasSize } = activeProject.settings; - - return { - width: canvasSize.width, - height: canvasSize.height, - }; -} - function ProjectInfoView() { const editor = useEditor(); const activeProject = editor.project.getActive(); @@ -117,7 +91,7 @@ function ProjectInfoView() { return -1; }; - const currentCanvasSize = getCurrentCanvasSize({ activeProject }); + const currentCanvasSize = activeProject.settings.canvasSize; const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; const presetIndex = findPresetIndexByAspectRatio({ @@ -162,7 +136,7 @@ function ProjectInfoView() { value={selectedPresetValue} onValueChange={(value) => handleAspectRatioChange({ value })} > - + @@ -190,7 +164,7 @@ function ProjectInfoView() { value={activeProject.settings.fps.toString()} onValueChange={handleFpsChange} > - + @@ -219,7 +193,7 @@ const BlurPreview = memo( }) => ( + +
+ +
); } function PreviewCanvas() { const canvasRef = useRef(null); + const containerRef = useRef(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef(null); const renderingRef = useRef(false); - const { width, height } = usePreviewSize(); + const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); + const containerSize = useContainerSize({ containerRef }); const editor = useEditor(); const activeProject = editor.project.getActive(); const renderer = useMemo(() => { return new CanvasRenderer({ - width, - height, + width: nativeWidth, + height: nativeHeight, fps: activeProject.settings.fps, }); - }, [width, height, activeProject.settings.fps]); + }, [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(); @@ -109,13 +223,18 @@ function PreviewCanvas() { useRafLoop(render); return ( -
+
+ +
+

It's empty here

+

+ Click an element on the timeline to edit its properties +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 3a552b93..05ad5396 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -4,8 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { AudioProperties } from "./audio-properties"; import { VideoProperties } from "./video-properties"; import { TextProperties } from "./text-properties"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Settings05Icon } from "@hugeicons/core-free-icons"; +import { EmptyView } from "./empty-view"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; @@ -18,9 +17,9 @@ export function PropertiesPanel() { }); return ( - <> +
{selectedElements.length > 0 ? ( - + {elementsWithTracks.map(({ track, element }) => { if (element.type === "text") { return ( @@ -45,24 +44,6 @@ export function PropertiesPanel() { ) : ( )} - - ); -} - -function EmptyView() { - return ( -
- -
-

It's empty here

-

- Click an element on the timeline to edit its properties -

-
); } diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx index 6d2fe444..a682097a 100644 --- a/apps/web/src/components/editor/panels/properties/property-item.tsx +++ b/apps/web/src/components/editor/panels/properties/property-item.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowDownIcon } from "@hugeicons/core-free-icons"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; interface PropertyItemProps { direction?: "row" | "column"; @@ -57,35 +57,76 @@ interface PropertyGroupProps { title: string; children: React.ReactNode; defaultExpanded?: boolean; + collapsible?: boolean; className?: string; - titleClassName?: string; + hasBorderTop?: boolean; + hasBorderBottom?: boolean; } export function PropertyGroup({ title, children, defaultExpanded = true, + collapsible = true, className, - titleClassName, + hasBorderTop = true, + hasBorderBottom = true, }: PropertyGroupProps) { const [isExpanded, setIsExpanded] = useState(defaultExpanded); return ( - - - {isExpanded && {children}} - +
+ {collapsible ? ( + + ) : ( +
+ {title} +
+ )} + {(collapsible ? isExpanded : true) && ( +
{children}
+ )} +
+ ); +} + +function PropertyGroupTitle({ + children, + isExpanded = false, +}: { + children: React.ReactNode; + isExpanded?: boolean; +}) { + return ( + + {children} + ); } diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index 35230c75..22e50e4e 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -5,29 +5,17 @@ import type { TextElement } from "@/types/timeline"; import { Slider } from "@/components/ui/slider"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { useState, useRef } from "react"; +import { useReducer, useRef } from "react"; import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; import { - TEXT_PROPERTIES_TABS, - isTextPropertiesTab, - useTextPropertiesStore, -} from "@/stores/text-properties-store"; -import { + PropertyGroup, PropertyItem, PropertyItemLabel, PropertyItemValue, } from "./property-item"; import { ColorPicker } from "@/components/ui/color-picker"; -import { cn } from "@/utils/ui"; -import { capitalizeFirstLetter, uppercase } from "@/utils/string"; +import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { LayoutGridIcon } from "@hugeicons/core-free-icons"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants"; @@ -40,48 +28,86 @@ export function TextProperties({ trackId: string; }) { const editor = useEditor(); - const { activeTab, setActiveTab } = useTextPropertiesStore(); const containerRef = useRef(null); - const [fontSizeInput, setFontSizeInput] = useState( - element.fontSize.toString(), - ); - const [opacityInput, setOpacityInput] = useState( - Math.round(element.opacity * 100).toString(), - ); + const [, forceRender] = useReducer((x: number) => x + 1, 0); + const isEditingFontSize = useRef(false); + const isEditingOpacity = useRef(false); + const isEditingContent = useRef(false); + const fontSizeDraft = useRef(""); + const opacityDraft = useRef(""); + const contentDraft = useRef(""); + + const fontSizeDisplay = isEditingFontSize.current + ? fontSizeDraft.current + : element.fontSize.toString(); + const opacityDisplay = isEditingOpacity.current + ? opacityDraft.current + : Math.round(element.opacity * 100).toString(); + const contentDisplay = isEditingContent.current + ? contentDraft.current + : element.content; const lastSelectedColor = useRef(DEFAULT_COLOR); const initialFontSizeRef = useRef(null); const initialOpacityRef = useRef(null); + const initialContentRef = useRef(null); + const initialColorRef = useRef(null); + const initialBgColorRef = useRef(null); const handleFontSizeChange = ({ value }: { value: string }) => { - setFontSizeInput(value); + fontSizeDraft.current = value; + forceRender(); if (value.trim() !== "") { + if (initialFontSizeRef.current === null) { + initialFontSizeRef.current = element.fontSize; + } const parsed = parseInt(value, 10); const fontSize = Number.isNaN(parsed) ? element.fontSize : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); editor.timeline.updateElements({ updates: [{ trackId, elementId: element.id, updates: { fontSize } }], + pushHistory: false, }); } }; const handleFontSizeBlur = () => { - const parsed = parseInt(fontSizeInput, 10); - const fontSize = Number.isNaN(parsed) - ? element.fontSize - : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - setFontSizeInput(fontSize.toString()); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - }); + if (initialFontSizeRef.current !== null) { + const parsed = parseInt(fontSizeDraft.current, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: initialFontSizeRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { fontSize } }], + pushHistory: true, + }); + initialFontSizeRef.current = null; + } + isEditingFontSize.current = false; + fontSizeDraft.current = ""; + forceRender(); }; const handleOpacityChange = ({ value }: { value: string }) => { - setOpacityInput(value); + opacityDraft.current = value; + forceRender(); if (value.trim() !== "") { + if (initialOpacityRef.current === null) { + initialOpacityRef.current = element.opacity; + } const parsed = parseInt(value, 10); const opacityPercent = Number.isNaN(parsed) ? Math.round(element.opacity * 100) @@ -94,234 +120,333 @@ export function TextProperties({ updates: { opacity: opacityPercent / 100 }, }, ], + pushHistory: false, }); } }; const handleOpacityBlur = () => { - const parsed = parseInt(opacityInput, 10); - const opacityPercent = Number.isNaN(parsed) - ? Math.round(element.opacity * 100) - : clamp({ value: parsed, min: 0, max: 100 }); - setOpacityInput(opacityPercent.toString()); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - }); + if (initialOpacityRef.current !== null) { + const parsed = parseInt(opacityDraft.current, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: initialOpacityRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }, + ], + pushHistory: true, + }); + initialOpacityRef.current = null; + } + isEditingOpacity.current = false; + opacityDraft.current = ""; + forceRender(); }; const handleColorChange = ({ color }: { color: string }) => { if (color !== "transparent") { lastSelectedColor.current = color; } - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, - ], - }); + if (initialBgColorRef.current === null) { + initialBgColorRef.current = element.backgroundColor; + } + if (initialBgColorRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }, + ], + pushHistory: false, + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }, + ], + }); + } }; - const handleTransparentToggle = ({ - isTransparent, - }: { - isTransparent: boolean; - }) => { - const newColor = isTransparent ? "transparent" : lastSelectedColor.current; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: newColor }, - }, - ], - }); + const handleColorChangeEnd = ({ color }: { color: string }) => { + if (initialBgColorRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: initialBgColorRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: `#${color}` }, + }, + ], + pushHistory: true, + }); + initialBgColorRef.current = null; + } }; return ( - { - if (isTextPropertiesTab(v)) setActiveTab(v); - }} - ref={containerRef} - tabs={TEXT_PROPERTIES_TABS.map((t) => ({ - value: t.value, - label: t.label, - content: - t.value === "transform" ? ( -
- ) : ( -
-