summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels')
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx29
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx9
-rw-r--r--apps/web/src/components/editor/panels/assets/views/media.tsx6
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx103
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx2
-rw-r--r--apps/web/src/components/editor/panels/panel-base-view.tsx4
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx141
-rw-r--r--apps/web/src/components/editor/panels/properties/empty-view.tsx20
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx25
-rw-r--r--apps/web/src/components/editor/panels/properties/property-item.tsx79
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx905
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx176
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx111
-rw-r--r--apps/web/src/components/editor/panels/timeline/drag-line.tsx28
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx535
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx52
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx566
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx92
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx126
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx39
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx285
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx112
26 files changed, 2911 insertions, 546 deletions
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({
<AspectRatio
ratio={aspectRatio}
className={cn(
- "bg-panel-accent relative overflow-hidden",
+ "bg-accent relative overflow-hidden",
isRounded && "rounded-sm",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
)}
@@ -215,7 +215,7 @@ function PlusButton({
<Button
size="icon"
className={cn(
- "bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5",
+ "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
className,
)}
onClick={(e) => {
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 (
- <div className="bg-panel flex h-full">
+ <div className="panel bg-background flex h-full rounded-sm border overflow-hidden">
<TabBar />
<Separator orientation="vertical" />
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
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<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false);
const [showBottomFade, setShowBottomFade] = useState(false);
+ const scrollRef = useRef<HTMLDivElement>(null);
const checkScrollPosition = useCallback(() => {
const element = scrollRef.current;
@@ -48,32 +49,24 @@ export function TabBar() {
<div className="relative flex">
<div
ref={scrollRef}
- className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4"
+ className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto"
>
{TAB_KEYS.map((tabKey) => {
const tab = tabs[tabKey];
return (
<Tooltip key={tabKey} delayDuration={10}>
<TooltipTrigger asChild>
- <button
- type="button"
+ <Button
+ variant={activeTab === tabKey ? "secondary" : "text"}
aria-label={tab.label}
className={cn(
- "flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75",
- activeTab === tabKey
- ? "text-primary !opacity-100"
- : "text-muted-foreground",
+ "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
+ activeTab !== tabKey && "border border-transparent text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- setActiveTab(tabKey);
- }
- }}
>
- <tab.icon className=" " />
- </button>
+ <tab.icon />
+ </Button>
</TooltipTrigger>
<TooltipContent
side="right"
@@ -108,8 +101,8 @@ function FadeOverlay({
className={cn(
"pointer-events-none absolute right-0 left-0 h-6",
direction === "top" && show
- ? "from-panel top-0 bg-gradient-to-b to-transparent"
- : "from-panel bottom-0 bg-gradient-to-t to-transparent",
+ ? "from-background top-0 bg-gradient-to-b to-transparent"
+ : "from-background bottom-0 bg-gradient-to-t to-transparent",
)}
/>
);
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"
>
- <PropertyGroup title="Language">
+ <div className="flex flex-col gap-3">
+ <Label>Language</Label>
<Select
value={selectedLanguage}
onValueChange={(value) => handleLanguageChange({ value })}
>
- <SelectTrigger className="bg-panel-accent h-8 w-full text-xs">
+ <SelectTrigger>
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
@@ -129,7 +130,7 @@ export function Captions() {
))}
</SelectContent>
</Select>
- </PropertyGroup>
+ </div>
<div className="flex flex-col gap-4">
{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}
>
- <div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
+ <div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b">
<span className="text-muted-foreground text-sm">Assets</span>
<div className="flex items-center gap-0">
<TooltipProvider>
@@ -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"
>
<HugeiconsIcon icon={CloudUploadIcon} />
- Upload
+ Import
</Button>
</div>
</div>
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 <ProjectSettingsTabs />;
@@ -56,24 +54,9 @@ function ProjectSettingsTabs() {
label: "Background",
content: (
<div className="flex h-full flex-col justify-between">
- <div className="flex-1 p-5">
+ <div className="flex-1">
<BackgroundView />
</div>
- {/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg">
- <Separator />
- <Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none">
- Custom background
- <PlusIcon />
- </Button>
- </div> */}
-
- {/* another ui */}
- {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
- <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
- <span className="text-sm">Custom</span>
- <PlusIcon className="" />
- </Button>
- </div> */}
</div>
),
},
@@ -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 })}
>
- <SelectTrigger className="bg-panel-accent">
+ <SelectTrigger>
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
@@ -190,7 +164,7 @@ function ProjectInfoView() {
value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange}
>
- <SelectTrigger className="bg-panel-accent">
+ <SelectTrigger>
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
@@ -219,7 +193,7 @@ const BlurPreview = memo(
}) => (
<button
className={cn(
- "border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border",
+ "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
@@ -265,7 +239,7 @@ const BackgroundPreviews = memo(
<button
key={`${index}-${bg}`}
className={cn(
- "border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
+ "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
@@ -347,48 +321,35 @@ function BackgroundView() {
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
+ const backgroundSections = [
+ { title: "Colors", backgrounds: colors, useBackgroundColor: true },
+ { title: "Pattern craft", backgrounds: patternCraftGradients },
+ { title: "Syntax UI", backgrounds: syntaxUIGradients },
+ ];
+
return (
- <div className="flex h-full flex-col gap-4">
- <PropertyGroup title="Blur" defaultExpanded={false}>
- <div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div>
+ <div className="flex h-full flex-col">
+ <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
+ <div className="flex flex-wrap gap-2">{blurPreviews}</div>
</PropertyGroup>
- <PropertyGroup title="Colors" defaultExpanded={false}>
- <div className="grid w-full grid-cols-4 gap-2">
- <div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border">
- <HugeiconsIcon icon={DropperIcon} className="size-4" />
+ {backgroundSections.map((section) => (
+ <PropertyGroup
+ key={section.title}
+ title={section.title}
+ defaultExpanded={false}
+ >
+ <div className="flex flex-wrap gap-2">
+ <BackgroundPreviews
+ backgrounds={section.backgrounds}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
+ useBackgroundColor={section.useBackgroundColor}
+ />
</div>
- <BackgroundPreviews
- backgrounds={colors}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
- useBackgroundColor={true}
- />
- </div>
- </PropertyGroup>
-
- <PropertyGroup title="Pattern craft" defaultExpanded={false}>
- <div className="grid w-full grid-cols-4 gap-2">
- <BackgroundPreviews
- backgrounds={patternCraftGradients}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
- />
- </div>
- </PropertyGroup>
-
- <PropertyGroup title="Syntax UI" defaultExpanded={false}>
- <div className="grid w-full grid-cols-4 gap-2">
- <BackgroundPreviews
- backgrounds={syntaxUIGradients}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
- />
- </div>
- </PropertyGroup>
+ </PropertyGroup>
+ ))}
</div>
);
}
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 84a5d0bf..30c70a83 100644
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -235,7 +235,7 @@ function SoundEffectsView() {
<div className="flex items-center gap-3">
<Input
placeholder="Search sound effects"
- className="bg-panel-accent w-full"
+ className="bg-accent w-full"
containerClassName="w-full"
value={searchQuery}
onChange={({ currentTarget }) =>
@@ -406,7 +406,7 @@ function SavedSoundsView() {
if (savedSounds.length === 0) {
return (
- <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
icon={FavouriteIcon}
className="text-muted-foreground size-10"
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 6b48e775..68ba91d2 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -149,7 +149,7 @@ function CollectionGrid({
function EmptyView({ message }: { message: string }) {
return (
- <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
icon={HappyIcon}
className="text-muted-foreground size-10"
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 6ccf5d98..6360c363 100644
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -27,7 +27,7 @@ export function TextView() {
<DraggableItem
name="Default text"
preview={
- <div className="bg-panel-accent flex size-full items-center justify-center rounded">
+ <div className="bg-accent flex size-full items-center justify-center rounded">
<span className="text-xs select-none">Default text</span>
</div>
}
diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx
index eec063ec..dd6151d3 100644
--- a/apps/web/src/components/editor/panels/panel-base-view.tsx
+++ b/apps/web/src/components/editor/panels/panel-base-view.tsx
@@ -26,7 +26,7 @@ function ViewContent({
className?: string;
}) {
return (
- <ScrollArea className="flex-1">
+ <ScrollArea className="flex-1 scrollbar-hidden">
<div className={cn("p-5", className)}>{children}</div>
</ScrollArea>
);
@@ -52,7 +52,7 @@ export function PanelBaseView({
onValueChange={onValueChange}
className="flex h-full flex-col"
>
- <div className="bg-panel sticky top-0 z-10">
+ <div className="bg-background sticky top-0 z-10">
<div className="px-3 pt-3 pb-0">
<TabsList>
{tabs.map((tab) => (
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index c579b15b..902547bd 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -4,11 +4,23 @@ import { useCallback, useMemo, useRef } from "react";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/hooks/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop";
+import { useContainerSize } from "@/hooks/use-container-size";
+import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
-import { getLastFrameTime } from "@/lib/time";
+import { formatTimeCode, getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
+import { EditableTimecode } from "@/components/editable-timecode";
+import { invokeAction } from "@/lib/actions";
+import { Button } from "@/components/ui/button";
+import {
+ FullScreenIcon,
+ PauseIcon,
+ PlayIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { cn } from "@/utils/ui";
function usePreviewSize() {
const editor = useEditor();
@@ -47,32 +59,134 @@ function RenderTreeController() {
}
export function PreviewPanel() {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
+
return (
- <div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm">
- <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2">
+ <div
+ ref={containerRef}
+ className={cn(
+ "panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border",
+ isFullscreen && "bg-background",
+ )}
+ >
+ <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
<PreviewCanvas />
<RenderTreeController />
</div>
+ <PreviewToolbar
+ isFullscreen={isFullscreen}
+ onToggleFullscreen={toggleFullscreen}
+ />
+ </div>
+ );
+}
+
+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;
+
+ return (
+ <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
+ <div className="flex items-center mt-1">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
+ <span className="text-muted-foreground font-mono text-xs">
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+ </span>
+ </div>
+
+ <Button
+ variant="text"
+ size="icon"
+ type="button"
+ onClick={() => invokeAction("toggle-play")}
+ >
+ <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
+ </Button>
+
+ <div className="justify-self-end">
+ <Button
+ variant="text"
+ size="icon"
+ type="button"
+ onClick={onToggleFullscreen}
+ title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
+ >
+ <HugeiconsIcon icon={FullScreenIcon} />
+ </Button>
+ </div>
</div>
);
}
function PreviewCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(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 (
- <div className="relative">
+ <div
+ ref={containerRef}
+ className="relative flex h-full w-full items-center justify-center"
+ >
<canvas
ref={canvasRef}
- width={width}
- height={height}
- className="block max-h-full max-w-full border"
+ width={nativeWidth}
+ height={nativeHeight}
+ className="block border"
style={{
+ width: displaySize.width,
+ height: displaySize.height,
background:
activeProject.settings.background.type === "blur"
? "transparent"
diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx
new file mode 100644
index 00000000..8ccf07ee
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx
@@ -0,0 +1,20 @@
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Settings05Icon } from "@hugeicons/core-free-icons";
+
+export function EmptyView() {
+ return (
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={Settings05Icon}
+ className="text-muted-foreground/75 size-10"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium ">It's empty here</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click an element on the timeline to edit its properties
+ </p>
+ </div>
+ </div>
+ );
+} \ 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 (
- <>
+ <div className="panel bg-background h-full rounded-sm border overflow-hidden">
{selectedElements.length > 0 ? (
- <ScrollArea className="bg-panel h-full rounded-sm">
+ <ScrollArea className="h-full">
{elementsWithTracks.map(({ track, element }) => {
if (element.type === "text") {
return (
@@ -45,24 +44,6 @@ export function PropertiesPanel() {
) : (
<EmptyView />
)}
- </>
- );
-}
-
-function EmptyView() {
- return (
- <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
- <HugeiconsIcon
- icon={Settings05Icon}
- className="text-muted-foreground/75 size-10"
- strokeWidth={1}
- />
- <div className="flex flex-col gap-2 text-center">
- <p className="text-lg font-medium">It's empty here</p>
- <p className="text-muted-foreground text-sm text-balance">
- Click an element on the timeline to edit its properties
- </p>
- </div>
</div>
);
}
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 (
- <PropertyItem direction="column" className={cn("gap-3", className)}>
- <button
- type="button"
- className="flex items-center gap-1.5 cursor-pointer"
- onClick={() => setIsExpanded(!isExpanded)}
- >
- <PropertyItemLabel className={cn(titleClassName)}>
- {title}
- </PropertyItemLabel>
- <HugeiconsIcon
- icon={ArrowDownIcon}
- className={cn("size-3", !isExpanded && "-rotate-90")}
- />
- </button>
- {isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
- </PropertyItem>
+ <div
+ className={cn(
+ "flex flex-col",
+ hasBorderTop && "border-t",
+ hasBorderBottom && "last:border-b",
+ className,
+ )}
+ >
+ {collapsible ? (
+ <button
+ type="button"
+ className="flex items-center justify-between p-3.5 cursor-pointer"
+ onClick={() => setIsExpanded(!isExpanded)}
+ >
+ <PropertyGroupTitle isExpanded={isExpanded}>
+ {title}
+ </PropertyGroupTitle>
+ <HugeiconsIcon
+ icon={isExpanded ? MinusSignIcon : PlusSignIcon}
+ className={cn(
+ "size-3",
+ isExpanded ? "text-foreground" : "text-muted-foreground",
+ )}
+ />
+ </button>
+ ) : (
+ <div className="flex items-center justify-between p-4">
+ <PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle>
+ </div>
+ )}
+ {(collapsible ? isExpanded : true) && (
+ <div className="p-3 pt-0">{children}</div>
+ )}
+ </div>
+ );
+}
+
+function PropertyGroupTitle({
+ children,
+ isExpanded = false,
+}: {
+ children: React.ReactNode;
+ isExpanded?: boolean;
+}) {
+ return (
+ <span
+ className={cn(
+ "text-xs font-medium",
+ isExpanded ? "text-foreground" : "text-muted-foreground",
+ )}
+ >
+ {children}
+ </span>
);
}
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<HTMLDivElement>(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<number | null>(null);
const initialOpacityRef = useRef<number | null>(null);
+ const initialContentRef = useRef<string | null>(null);
+ const initialColorRef = useRef<string | null>(null);
+ const initialBgColorRef = useRef<string | null>(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 (
- <PanelBaseView
- defaultTab="transform"
- value={activeTab}
- onValueChange={(v) => {
- if (isTextPropertiesTab(v)) setActiveTab(v);
- }}
- ref={containerRef}
- tabs={TEXT_PROPERTIES_TABS.map((t) => ({
- value: t.value,
- label: t.label,
- content:
- t.value === "transform" ? (
- <div className="space-y-6"></div>
- ) : (
- <div className="space-y-6">
- <Textarea
- placeholder="Name"
- defaultValue={element.content}
- className="bg-panel-accent min-h-20"
- onChange={(e) =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { content: e.target.value },
- },
- ],
- })
- }
- />
- <PropertyItem direction="column">
- <PropertyItemLabel>Font</PropertyItemLabel>
- <PropertyItemValue>
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value: FontFamily) =>
+ <div className="flex h-full flex-col" ref={containerRef}>
+ <PanelBaseView className="p-0">
+ <PropertyGroup title="Content" hasBorderTop={false} collapsible={false}>
+ <Textarea
+ placeholder="Name"
+ value={contentDisplay}
+ className="bg-accent min-h-20"
+ onFocus={() => {
+ isEditingContent.current = true;
+ contentDraft.current = element.content;
+ initialContentRef.current = element.content;
+ forceRender();
+ }}
+ onChange={(event) => {
+ contentDraft.current = event.target.value;
+ forceRender();
+ if (initialContentRef.current === null) {
+ initialContentRef.current = element.content;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { content: event.target.value },
+ },
+ ],
+ pushHistory: false,
+ });
+ }}
+ onBlur={() => {
+ if (initialContentRef.current !== null) {
+ const finalContent = contentDraft.current;
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { content: initialContentRef.current },
+ },
+ ],
+ pushHistory: false,
+ });
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { content: finalContent },
+ },
+ ],
+ pushHistory: true,
+ });
+ initialContentRef.current = null;
+ }
+ isEditingContent.current = false;
+ contentDraft.current = "";
+ forceRender();
+ }}
+ />
+ </PropertyGroup>
+ <PropertyGroup title="Typography" collapsible={false}>
+ <div className="space-y-6">
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Font</PropertyItemLabel>
+ <PropertyItemValue>
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value: FontFamily) =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontFamily: value },
+ },
+ ],
+ })
+ }
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Style</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Button
+ variant={
+ element.fontWeight === "bold" ? "default" : "outline"
+ }
+ size="sm"
+ onClick={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { fontFamily: value },
+ updates: {
+ fontWeight:
+ element.fontWeight === "bold"
+ ? "normal"
+ : "bold",
+ },
},
],
})
}
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Style</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Button
- variant={
- element.fontWeight === "bold" ? "default" : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontWeight:
- element.fontWeight === "bold"
- ? "normal"
- : "bold",
- },
+ className="h-8 px-3 font-bold"
+ >
+ B
+ </Button>
+ <Button
+ variant={
+ element.fontStyle === "italic" ? "default" : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontStyle:
+ element.fontStyle === "italic"
+ ? "normal"
+ : "italic",
},
- ],
- })
- }
- className="h-8 px-3 font-bold"
- >
- B
- </Button>
- <Button
- variant={
- element.fontStyle === "italic" ? "default" : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontStyle:
- element.fontStyle === "italic"
- ? "normal"
- : "italic",
- },
+ },
+ ],
+ })
+ }
+ className="h-8 px-3 italic"
+ >
+ I
+ </Button>
+ <Button
+ variant={
+ element.textDecoration === "underline"
+ ? "default"
+ : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ textDecoration:
+ element.textDecoration === "underline"
+ ? "none"
+ : "underline",
},
- ],
- })
- }
- className="h-8 px-3 italic"
- >
- I
- </Button>
- <Button
- variant={
- element.textDecoration === "underline"
- ? "default"
- : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- textDecoration:
- element.textDecoration === "underline"
- ? "none"
- : "underline",
- },
+ },
+ ],
+ })
+ }
+ className="h-8 px-3 underline"
+ >
+ U
+ </Button>
+ <Button
+ variant={
+ element.textDecoration === "line-through"
+ ? "default"
+ : "outline"
+ }
+ size="sm"
+ onClick={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ textDecoration:
+ element.textDecoration === "line-through"
+ ? "none"
+ : "line-through",
},
- ],
- })
- }
- className="h-8 px-3 underline"
- >
- U
- </Button>
- <Button
- variant={
- element.textDecoration === "line-through"
- ? "default"
- : "outline"
+ },
+ ],
+ })
+ }
+ className="h-8 px-3 line-through"
+ >
+ S
+ </Button>
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Font size</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Slider
+ value={[element.fontSize]}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ step={1}
+ onValueChange={([value]) => {
+ if (initialFontSizeRef.current === null) {
+ initialFontSizeRef.current = element.fontSize;
}
- size="sm"
- onClick={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontSize: value },
+ },
+ ],
+ pushHistory: false,
+ });
+ }}
+ onValueCommit={([value]) => {
+ if (initialFontSizeRef.current !== null) {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
- textDecoration:
- element.textDecoration === "line-through"
- ? "none"
- : "line-through",
+ fontSize: initialFontSizeRef.current,
},
},
],
- })
- }
- className="h-8 px-3 line-through"
- >
- S
- </Button>
- </div>
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Font size</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.fontSize]}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- step={1}
- onValueChange={([value]) => {
- if (initialFontSizeRef.current === null) {
- initialFontSizeRef.current = element.fontSize;
- }
+ pushHistory: false,
+ });
editor.timeline.updateElements({
updates: [
{
@@ -330,61 +455,48 @@ export function TextProperties({
updates: { fontSize: value },
},
],
- pushHistory: false,
+ pushHistory: true,
});
- setFontSizeInput(value.toString());
- }}
- onValueCommit={([value]) => {
- if (initialFontSizeRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontSize: initialFontSizeRef.current,
- },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontSize: value },
- },
- ],
- pushHistory: true,
- });
- initialFontSizeRef.current = null;
- }
- }}
- className="w-full"
- />
- <Input
- type="number"
- value={fontSizeInput}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onChange={(e) =>
- handleFontSizeChange({ value: e.target.value })
+ initialFontSizeRef.current = null;
}
- onBlur={handleFontSizeBlur}
- className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Color</PropertyItemLabel>
- <PropertyItemValue>
- <ColorPicker
- value={uppercase({
- string: (element.color || "FFFFFF").replace("#", ""),
- })}
- onChange={(color) => {
+ }}
+ className="w-full"
+ />
+ <Input
+ type="number"
+ value={fontSizeDisplay}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ onFocus={() => {
+ isEditingFontSize.current = true;
+ fontSizeDraft.current = element.fontSize.toString();
+ forceRender();
+ }}
+ onChange={(e) =>
+ handleFontSizeChange({ value: e.target.value })
+ }
+ onBlur={handleFontSizeBlur}
+ className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ />
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ </div>
+ </PropertyGroup>
+ <PropertyGroup title="Appearance" collapsible={false}>
+ <div className="space-y-6">
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Color</PropertyItemLabel>
+ <PropertyItemValue>
+ <ColorPicker
+ value={uppercase({
+ string: (element.color || "FFFFFF").replace("#", ""),
+ })}
+ onChange={(color) => {
+ if (initialColorRef.current === null) {
+ initialColorRef.current = element.color || "#FFFFFF";
+ }
+ if (initialColorRef.current !== null) {
editor.timeline.updateElements({
updates: [
{
@@ -393,131 +505,146 @@ export function TextProperties({
updates: { color: `#${color}` },
},
],
+ pushHistory: false,
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { color: `#${color}` },
+ },
+ ],
+ });
+ }
+ }}
+ onChangeEnd={(color) => {
+ if (initialColorRef.current !== null) {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { color: initialColorRef.current },
+ },
+ ],
+ pushHistory: false,
+ });
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { color: `#${color}` },
+ },
+ ],
+ pushHistory: true,
+ });
+ initialColorRef.current = null;
+ }
+ }}
+ containerRef={containerRef}
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Opacity</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Slider
+ value={[element.opacity * 100]}
+ min={0}
+ max={100}
+ step={1}
+ onValueChange={([value]) => {
+ if (initialOpacityRef.current === null) {
+ initialOpacityRef.current = element.opacity;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: value / 100 },
+ },
+ ],
+ pushHistory: false,
});
}}
- containerRef={containerRef}
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Opacity</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.opacity * 100]}
- min={0}
- max={100}
- step={1}
- onValueChange={([value]) => {
- if (initialOpacityRef.current === null) {
- initialOpacityRef.current = element.opacity;
- }
+ onValueCommit={([value]) => {
+ if (initialOpacityRef.current !== null) {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { opacity: value / 100 },
+ updates: { opacity: initialOpacityRef.current },
},
],
pushHistory: false,
});
- setOpacityInput(value.toString());
- }}
- onValueCommit={([value]) => {
- if (initialOpacityRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: initialOpacityRef.current },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: value / 100 },
- },
- ],
- pushHistory: true,
- });
- initialOpacityRef.current = null;
- }
- }}
- className="w-full"
- />
- <Input
- type="number"
- value={opacityInput}
- min={0}
- max={100}
- onChange={(e) =>
- handleOpacityChange({ value: e.target.value })
- }
- onBlur={handleOpacityBlur}
- className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Background</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <ColorPicker
- value={capitalizeFirstLetter({
- string:
- element.backgroundColor === "transparent"
- ? lastSelectedColor.current.replace("#", "")
- : element.backgroundColor.replace("#", ""),
- })}
- onChange={(color) =>
- handleColorChange({ color: `#${color}` })
- }
- containerRef={containerRef}
- className={
- element.backgroundColor === "transparent"
- ? "pointer-events-none opacity-50"
- : ""
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: value / 100 },
+ },
+ ],
+ pushHistory: true,
+ });
+ initialOpacityRef.current = null;
}
- />
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="icon"
- onClick={() =>
- handleTransparentToggle({
- isTransparent:
- element.backgroundColor !== "transparent",
- })
- }
- className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
- >
- <HugeiconsIcon
- icon={LayoutGridIcon}
- className={cn(
- "text-foreground",
- element.backgroundColor === "transparent" &&
- "text-primary",
- )}
- />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Transparent background</TooltipContent>
- </Tooltip>
- </div>
- </PropertyItemValue>
- </PropertyItem>
- </div>
- ),
- }))}
- />
+ }}
+ className="w-full"
+ />
+ <Input
+ type="number"
+ value={opacityDisplay}
+ min={0}
+ max={100}
+ onFocus={() => {
+ isEditingOpacity.current = true;
+ opacityDraft.current = Math.round(
+ element.opacity * 100,
+ ).toString();
+ forceRender();
+ }}
+ onChange={(e) =>
+ handleOpacityChange({ value: e.target.value })
+ }
+ onBlur={handleOpacityBlur}
+ className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ />
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Background</PropertyItemLabel>
+ <PropertyItemValue>
+ <ColorPicker
+ value={
+ element.backgroundColor === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : element.backgroundColor.replace("#", "")
+ }
+ onChange={(color) =>
+ handleColorChange({ color: `#${color}` })
+ }
+ onChangeEnd={(color) => handleColorChangeEnd({ color })}
+ containerRef={containerRef}
+ className={
+ element.backgroundColor === "transparent"
+ ? "pointer-events-none opacity-50"
+ : ""
+ }
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ </div>
+ </PropertyGroup>
+ </PanelBaseView>
+ </div>
);
}
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
new file mode 100644
index 00000000..bbc5cb83
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -0,0 +1,176 @@
+import { useEffect, useRef, useState } from "react";
+import WaveSurfer from "wavesurfer.js";
+
+interface AudioWaveformProps {
+ audioUrl?: string;
+ audioBuffer?: AudioBuffer;
+ height?: number;
+ className?: string;
+}
+
+function extractPeaks({
+ buffer,
+ length = 512,
+}: {
+ buffer: AudioBuffer;
+ length?: number;
+}): number[][] {
+ const channels = buffer.numberOfChannels;
+ const peaks: number[][] = [];
+
+ for (let c = 0; c < channels; c++) {
+ const data = buffer.getChannelData(c);
+ const step = Math.floor(data.length / length);
+ const channelPeaks: number[] = [];
+
+ for (let i = 0; i < length; i++) {
+ const start = i * step;
+ const end = Math.min(start + step, data.length);
+ let max = 0;
+ for (let j = start; j < end; j++) {
+ const abs = Math.abs(data[j]);
+ if (abs > max) max = abs;
+ }
+ channelPeaks.push(max);
+ }
+ peaks.push(channelPeaks);
+ }
+
+ return peaks;
+}
+
+export function AudioWaveform({
+ audioUrl,
+ audioBuffer,
+ height = 32,
+ className = "",
+}: AudioWaveformProps) {
+ const waveformRef = useRef<HTMLDivElement>(null);
+ const wavesurfer = useRef<WaveSurfer | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+ const ws = wavesurfer.current;
+
+ const initWaveSurfer = async () => {
+ if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
+
+ try {
+ if (ws) {
+ wavesurfer.current = null;
+ }
+
+ const newWaveSurfer = WaveSurfer.create({
+ container: waveformRef.current,
+ waveColor: "rgba(255, 255, 255, 0.6)",
+ progressColor: "rgba(255, 255, 255, 0.9)",
+ cursorColor: "transparent",
+ barWidth: 2,
+ barGap: 1,
+ height,
+ normalize: true,
+ interact: false,
+ });
+
+ if (mounted) {
+ wavesurfer.current = newWaveSurfer;
+ } else {
+ try {
+ newWaveSurfer.destroy();
+ } catch {}
+ return;
+ }
+
+ newWaveSurfer.on("ready", () => {
+ if (mounted) {
+ setIsLoading(false);
+ setError(false);
+ }
+ });
+
+ newWaveSurfer.on("error", (err) => {
+ if (mounted) {
+ console.error("WaveSurfer error:", err);
+ setError(true);
+ setIsLoading(false);
+ }
+ });
+
+ if (audioBuffer) {
+ const peaks = extractPeaks({ buffer: audioBuffer });
+ newWaveSurfer.load("", peaks, audioBuffer.duration);
+ } else if (audioUrl) {
+ await newWaveSurfer.load(audioUrl);
+ }
+ } catch (err) {
+ if (mounted) {
+ console.error("Failed to initialize WaveSurfer:", err);
+ setError(true);
+ setIsLoading(false);
+ }
+ }
+ };
+
+ if (ws) {
+ const wsToDestroy = ws;
+ wavesurfer.current = null;
+
+ requestAnimationFrame(() => {
+ try {
+ wsToDestroy.destroy();
+ } catch {}
+ if (mounted) {
+ initWaveSurfer();
+ }
+ });
+ } else {
+ initWaveSurfer();
+ }
+
+ return () => {
+ mounted = false;
+
+ const wsToDestroy = wavesurfer.current;
+
+ wavesurfer.current = null;
+
+ if (wsToDestroy) {
+ requestAnimationFrame(() => {
+ try {
+ wsToDestroy.destroy();
+ } catch {}
+ });
+ }
+ };
+ }, [audioUrl, audioBuffer, height]);
+
+ if (error) {
+ return (
+ <div
+ className={`flex items-center justify-center ${className}`}
+ style={{ height }}
+ >
+ <span className="text-foreground/60 text-xs">Audio unavailable</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className={`relative ${className}`}>
+ {isLoading && (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <span className="text-foreground/60 text-xs">Loading...</span>
+ </div>
+ )}
+ <div
+ ref={waveformRef}
+ className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`}
+ style={{ height }}
+ />
+ </div>
+ );
+}
+
+export default AudioWaveform;
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
new file mode 100644
index 00000000..cfcb1e23
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -0,0 +1,111 @@
+import { useEditor } from "@/hooks/use-editor";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { getSnappedSeekTime } from "@/lib/time";
+import { Bookmark02Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+interface TimelineBookmarksRowProps {
+ zoomLevel: number;
+ dynamicTimelineWidth: number;
+ handleWheel: (e: React.WheelEvent) => void;
+ handleTimelineContentClick: (e: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
+ handleRulerMouseDown: (e: React.MouseEvent) => void;
+}
+
+export function TimelineBookmarksRow({
+ zoomLevel,
+ dynamicTimelineWidth,
+ handleWheel,
+ handleTimelineContentClick,
+ handleRulerTrackingMouseDown,
+ handleRulerMouseDown,
+}: TimelineBookmarksRowProps) {
+ const editor = useEditor();
+ const activeScene = editor.scenes.getActiveScene();
+
+ return (
+ <div className="relative h-4 flex-1 overflow-hidden">
+ <button
+ className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ aria-label="Timeline ruler"
+ type="button"
+ onWheel={handleWheel}
+ onClick={handleTimelineContentClick}
+ onMouseDown={(event) => {
+ handleRulerMouseDown(event);
+ handleRulerTrackingMouseDown(event);
+ }}
+ >
+ {activeScene.bookmarks.map((time: number) => (
+ <TimelineBookmark
+ key={`bookmark-row-${time}`}
+ time={time}
+ zoomLevel={zoomLevel}
+ />
+ ))}
+ </button>
+ </div>
+ );
+}
+
+export function TimelineBookmark({
+ time,
+ zoomLevel,
+}: {
+ time: number;
+ zoomLevel: number;
+}) {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const duration = editor.timeline.getTotalDuration();
+
+ const handleBookmarkActivate = ({
+ event,
+ }: {
+ event:
+ | React.MouseEvent<HTMLButtonElement>
+ | React.KeyboardEvent<HTMLButtonElement>;
+ }) => {
+ event.stopPropagation();
+ const framesPerSecond = activeProject?.settings.fps ?? 30;
+ const snappedTime = getSnappedSeekTime({
+ rawTime: time,
+ duration,
+ fps: framesPerSecond,
+ });
+ editor.playback.seek({ time: snappedTime });
+ };
+
+ return (
+ <button
+ className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0"
+ style={{
+ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ aria-label={`Seek to bookmark at ${time}s`}
+ type="button"
+ onMouseDown={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ onClick={(event) => handleBookmarkActivate({ event })}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ handleBookmarkActivate({ event });
+ }}
+ >
+ <div className="text-primary absolute top-[-1px] left-[-5px]">
+ <HugeiconsIcon
+ icon={Bookmark02Icon}
+ aria-hidden="true"
+ className="fill-primary size-3"
+ />
+ </div>
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
new file mode 100644
index 00000000..f31c6e28
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
@@ -0,0 +1,28 @@
+import { getDropLineY } from "@/lib/timeline/drop-utils";
+import type { TimelineTrack, DropTarget } from "@/types/timeline";
+
+interface DragLineProps {
+ dropTarget: DropTarget | null;
+ tracks: TimelineTrack[];
+ isVisible: boolean;
+ headerHeight?: number;
+}
+
+export function DragLine({
+ dropTarget,
+ tracks,
+ isVisible,
+ headerHeight = 0,
+}: DragLineProps) {
+ if (!isVisible || !dropTarget) return null;
+
+ const y = getDropLineY({ dropTarget, tracks });
+ const lineTop = y + headerHeight;
+
+ return (
+ <div
+ className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
+ style={{ top: `${lineTop}px` }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
new file mode 100644
index 00000000..94edc2d0
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -0,0 +1,535 @@
+"use client";
+
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Delete02Icon,
+ TaskAdd02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "../../../ui/context-menu";
+import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
+import { useState, useRef, useCallback } from "react";
+import { TimelineTrackContent } from "./timeline-track";
+import { TimelinePlayhead } from "./timeline-playhead";
+import { SelectionBox } from "../../selection-box";
+import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
+import { SnapIndicator } from "./snap-indicator";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import type { TimelineTrack } from "@/types/timeline";
+import {
+ TIMELINE_CONSTANTS,
+ TRACK_ICONS,
+} from "@/constants/timeline-constants";
+import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
+import {
+ getTrackHeight,
+ getCumulativeHeightBefore,
+ getTotalTracksHeight,
+ canTracktHaveAudio,
+ canTrackBeHidden,
+ getTimelineZoomMin,
+ getTimelinePaddingPx,
+ isMainTrack,
+} from "@/lib/timeline";
+import { TimelineToolbar } from "./timeline-toolbar";
+import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
+import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
+import { TimelineRuler } from "./timeline-ruler";
+import { TimelineBookmarksRow } from "./bookmarks";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useEditor } from "@/hooks/use-editor";
+import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
+import { DragLine } from "./drag-line";
+import { invokeAction } from "@/lib/actions";
+
+export function Timeline() {
+ const tracksContainerHeight = { min: 0, max: 800 };
+ const { snappingEnabled } = useTimelineStore();
+ const { clearElementSelection, setElementSelection } = useElementSelection();
+ const editor = useEditor();
+ const timeline = editor.timeline;
+ const tracks = timeline.getTracks();
+ const seek = (time: number) => editor.playback.seek({ time });
+
+ // refs
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const timelineHeaderRef = useRef<HTMLDivElement>(null);
+ const rulerRef = useRef<HTMLDivElement>(null);
+ const tracksContainerRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
+ const trackLabelsRef = useRef<HTMLDivElement>(null);
+ const playheadRef = useRef<HTMLDivElement>(null);
+ const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
+
+ // state
+ const [isResizing, setIsResizing] = useState(false);
+ const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
+ null,
+ );
+
+ const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
+ setCurrentSnapPoint(snapPoint);
+ }, []);
+ const handleResizeStateChange = useCallback(
+ ({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
+ setIsResizing(nextIsResizing);
+ if (!nextIsResizing) {
+ setCurrentSnapPoint(null);
+ }
+ },
+ [],
+ );
+
+ const timelineDuration = timeline.getTotalDuration() || 0;
+ const minZoomLevel = getTimelineZoomMin({
+ duration: timelineDuration,
+ containerWidth: tracksContainerRef.current?.clientWidth,
+ });
+
+ const savedViewState = editor.project.getTimelineViewState();
+
+ const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } =
+ useTimelineZoom({
+ containerRef: timelineRef,
+ minZoom: minZoomLevel,
+ initialZoom: savedViewState?.zoomLevel,
+ initialScrollLeft: savedViewState?.scrollLeft,
+ initialPlayheadTime: savedViewState?.playheadTime,
+ tracksScrollRef,
+ rulerScrollRef: tracksScrollRef,
+ });
+
+ const {
+ dragState,
+ dragDropTarget,
+ handleElementMouseDown,
+ handleElementClick,
+ lastMouseXRef,
+ } = useElementInteraction({
+ zoomLevel,
+ timelineRef,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef: timelineHeaderRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
+
+ const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
+ useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef: tracksScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
+ containerRef: tracksContainerRef,
+ headerRef: timelineHeaderRef,
+ zoomLevel,
+ });
+
+ const {
+ selectionBox,
+ handleMouseDown: handleSelectionMouseDown,
+ isSelecting,
+ shouldIgnoreClick,
+ } = useSelectionBox({
+ containerRef: tracksContainerRef,
+ onSelectionComplete: (elements) => {
+ setElementSelection({ elements });
+ },
+ tracksScrollRef,
+ zoomLevel,
+ });
+
+ const containerWidth = tracksContainerRef.current?.clientWidth || 1000;
+ const contentWidth =
+ timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const paddingPx = getTimelinePaddingPx({
+ containerWidth,
+ zoomLevel,
+ minZoom: minZoomLevel,
+ });
+ const dynamicTimelineWidth = Math.max(
+ contentWidth + paddingPx,
+ containerWidth,
+ );
+
+ const showSnapIndicator =
+ snappingEnabled &&
+ currentSnapPoint !== null &&
+ (dragState.isDragging || isResizing);
+
+ const {
+ handleTracksMouseDown,
+ handleTracksClick,
+ handleRulerMouseDown,
+ handleRulerClick,
+ } = useTimelineSeek({
+ playheadRef,
+ trackLabelsRef,
+ rulerScrollRef: tracksScrollRef,
+ tracksScrollRef,
+ zoomLevel,
+ duration: timeline.getTotalDuration(),
+ isSelecting,
+ clearSelectedElements: clearElementSelection,
+ seek,
+ });
+
+ useScrollSync({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ });
+
+ const timelineHeaderHeight =
+ timelineHeaderRef.current?.getBoundingClientRect().height ?? 0;
+
+ return (
+ <section
+ className={
+ "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border"
+ }
+ {...dragProps}
+ aria-label="Timeline"
+ >
+ <TimelineToolbar
+ zoomLevel={zoomLevel}
+ minZoom={minZoomLevel}
+ setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
+ />
+
+ <div
+ className="relative flex flex-1 flex-col overflow-hidden"
+ ref={timelineRef}
+ >
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ timelineRef={timelineRef}
+ trackLabelsRef={trackLabelsRef}
+ tracksScrollRef={tracksScrollRef}
+ isVisible={showSnapIndicator}
+ />
+ <div className="flex flex-1 overflow-hidden">
+ <div className="bg-background flex w-28 shrink-0 flex-col border-r">
+ <div className="bg-background flex h-4 items-center justify-between px-3">
+ <span className="opacity-0">.</span>
+ </div>
+ <div className="bg-background flex h-4 items-center justify-between px-3">
+ <span className="opacity-0">.</span>
+ </div>
+ {tracks.length > 0 && (
+ <div
+ ref={trackLabelsRef}
+ className="bg-background flex-1 overflow-y-auto"
+ style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
+ >
+ <ScrollArea className="size-full" ref={trackLabelsScrollRef}>
+ <div className="flex flex-col gap-1">
+ {tracks.map((track) => (
+ <div
+ key={track.id}
+ className="group flex items-center px-3"
+ style={{
+ height: `${getTrackHeight({ type: track.type })}px`,
+ }}
+ >
+ <div className="flex min-w-0 flex-1 items-center justify-end gap-2">
+ {process.env.NODE_ENV === "development" &&
+ isMainTrack(track) && (
+ <div className="bg-red-500 size-1.5 rounded-full" />
+ )}
+ {canTracktHaveAudio(track) && (
+ <TrackToggleIcon
+ isOff={track.muted}
+ icons={{
+ on: VolumeHighIcon,
+ off: VolumeOffIcon,
+ }}
+ onClick={() =>
+ editor.timeline.toggleTrackMute({
+ trackId: track.id,
+ })
+ }
+ />
+ )}
+ {canTrackBeHidden(track) && (
+ <TrackToggleIcon
+ isOff={track.hidden}
+ icons={{
+ on: ViewIcon,
+ off: ViewOffSlashIcon,
+ }}
+ onClick={() =>
+ editor.timeline.toggleTrackVisibility({
+ trackId: track.id,
+ })
+ }
+ />
+ )}
+ <TrackIcon track={track} />
+ </div>
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+ </div>
+
+ <div
+ className="relative flex flex-1 flex-col overflow-hidden"
+ ref={tracksContainerRef}
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <DragLine
+ dropTarget={dropTarget}
+ tracks={timeline.getTracks()}
+ isVisible={isDragOver}
+ headerHeight={timelineHeaderHeight}
+ />
+ <DragLine
+ dropTarget={dragDropTarget}
+ tracks={timeline.getTracks()}
+ isVisible={dragState.isDragging}
+ headerHeight={timelineHeaderHeight}
+ />
+ <ScrollArea
+ className="size-full overflow-y-hidden"
+ ref={tracksScrollRef}
+ onMouseDown={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksClick(event);
+ }}
+ onWheel={(event) => {
+ if (
+ event.shiftKey ||
+ Math.abs(event.deltaX) > Math.abs(event.deltaY)
+ ) {
+ return;
+ }
+ handleWheel(event);
+ }}
+ onScroll={() => {
+ saveScrollPosition();
+ }}
+ >
+ <div
+ className="relative"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ >
+ <div
+ ref={timelineHeaderRef}
+ className="bg-background sticky top-0 z-30 flex flex-col"
+ >
+ <TimelineRuler
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ rulerRef={rulerRef}
+ tracksScrollRef={tracksScrollRef}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
+ <TimelineBookmarksRow
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
+ </div>
+ <TimelinePlayhead
+ zoomLevel={zoomLevel}
+ rulerRef={rulerRef}
+ rulerScrollRef={tracksScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ <div
+ className="relative"
+ style={{
+ height: `${Math.max(
+ tracksContainerHeight.min,
+ Math.min(
+ tracksContainerHeight.max,
+ getTotalTracksHeight({ tracks }),
+ ),
+ )}px`,
+ }}
+ >
+ {tracks.length === 0 ? (
+ <div />
+ ) : (
+ tracks.map((track, index) => (
+ <ContextMenu key={track.id}>
+ <ContextMenuTrigger asChild>
+ <div
+ className="absolute right-0 left-0"
+ style={{
+ top: `${getCumulativeHeightBefore({
+ tracks,
+ trackIndex: index,
+ })}px`,
+ height: `${getTrackHeight({
+ type: track.type,
+ })}px`,
+ }}
+ >
+ <TimelineTrackContent
+ track={track}
+ zoomLevel={zoomLevel}
+ dragState={dragState}
+ rulerScrollRef={tracksScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ lastMouseXRef={lastMouseXRef}
+ onSnapPointChange={handleSnapPointChange}
+ onResizeStateChange={handleResizeStateChange}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ onTrackMouseDown={(event) => {
+ handleSelectionMouseDown(event);
+ handleTracksMouseDown(event);
+ }}
+ onTrackClick={handleTracksClick}
+ shouldIgnoreClick={shouldIgnoreClick}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="z-200 w-40">
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
+ onClick={(e) => {
+ e.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(e) => {
+ e.stopPropagation();
+ timeline.toggleTrackMute({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ <span>
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(e) => {
+ e.stopPropagation();
+ timeline.toggleTrackVisibility({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={ViewIcon} />
+ <span>
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(e) => {
+ e.stopPropagation();
+ timeline.removeTrack({
+ trackId: track.id,
+ });
+ }}
+ variant="destructive"
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ Delete track
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ ))
+ )}
+ </div>
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </section>
+ );
+}
+
+function TrackIcon({ track }: { track: TimelineTrack }) {
+ return <>{TRACK_ICONS[track.type]}</>;
+}
+
+function TrackToggleIcon({
+ isOff,
+ icons,
+ onClick,
+}: {
+ isOff: boolean;
+ icons: {
+ on: IconSvgElement;
+ off: IconSvgElement;
+ };
+ onClick: () => void;
+}) {
+ return (
+ <>
+ {isOff ? (
+ <HugeiconsIcon
+ icon={icons.off}
+ className="text-destructive size-4 cursor-pointer"
+ onClick={onClick}
+ />
+ ) : (
+ <HugeiconsIcon
+ icon={icons.on}
+ className="text-muted-foreground size-4 cursor-pointer"
+ onClick={onClick}
+ />
+ )}
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
new file mode 100644
index 00000000..914edccb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import type { TimelineTrack } from "@/types/timeline";
+
+interface SnapIndicatorProps {
+ snapPoint: SnapPoint | null;
+ zoomLevel: number;
+ isVisible: boolean;
+ tracks: TimelineTrack[];
+ timelineRef: React.RefObject<HTMLDivElement | null>;
+ trackLabelsRef?: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+}
+
+export function SnapIndicator({
+ snapPoint,
+ zoomLevel,
+ isVisible,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+ tracksScrollRef,
+}: SnapIndicatorProps) {
+ const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+ tracksScrollRef,
+ });
+
+ if (!isVisible || !snapPoint) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute z-90"
+ style={{
+ left: `${leftPosition}px`,
+ top: topPosition,
+ height: `${height}px`,
+ width: "2px",
+ }}
+ >
+ <div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
new file mode 100644
index 00000000..59ce88da
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -0,0 +1,566 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import AudioWaveform from "./audio-waveform";
+import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ getTrackClasses,
+ getTrackHeight,
+ canElementHaveAudio,
+ canElementBeHidden,
+ hasMediaId,
+} from "@/lib/timeline";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "../../../ui/context-menu";
+import type {
+ TimelineElement as TimelineElementType,
+ TimelineTrack,
+ ElementDragState,
+} from "@/types/timeline";
+import type { MediaAsset } from "@/types/assets";
+import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import Image from "next/image";
+import {
+ ScissorIcon,
+ Delete02Icon,
+ Copy01Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+ VolumeMute02Icon,
+ Search01Icon,
+ Exchange01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { uppercase } from "@/utils/string";
+import type { ComponentProps } from "react";
+
+function getDisplayShortcut(action: TAction) {
+ const { defaultShortcuts } = getActionDefinition(action);
+ if (!defaultShortcuts?.length) {
+ return "";
+ }
+
+ return uppercase({
+ string: defaultShortcuts[0].replace("+", " "),
+ });
+}
+
+interface TimelineElementProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ zoomLevel: number;
+ isSelected: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ dragState: ElementDragState;
+}
+
+export function TimelineElement({
+ element,
+ track,
+ zoomLevel,
+ isSelected,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ dragState,
+}: TimelineElementProps) {
+ const editor = useEditor();
+ const { selectedElements } = useElementSelection();
+ const { requestRevealMedia } = useAssetsPanelStore();
+
+ const mediaAssets = editor.media.getAssets();
+ let mediaAsset: MediaAsset | null = null;
+
+ if (hasMediaId(element)) {
+ mediaAsset =
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ }
+
+ const hasAudio = mediaSupportsAudio({ media: mediaAsset });
+
+ const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
+ useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onSnapPointChange,
+ onResizeStateChange,
+ });
+
+ const isCurrentElementSelected = selectedElements.some(
+ (selected) =>
+ selected.elementId === element.id && selected.trackId === track.id,
+ );
+
+ const isBeingDragged = dragState.elementId === element.id;
+ const dragOffsetY =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentMouseY - dragState.startMouseY
+ : 0;
+ const elementStartTime =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentTime
+ : element.startTime;
+ const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
+ const displayedDuration = isResizing ? currentDuration : element.duration;
+ const elementWidth =
+ displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const elementLeft = displayedStartTime * 50 * zoomLevel;
+
+ const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
+ event.stopPropagation();
+ if (hasMediaId(element)) {
+ requestRevealMedia(element.mediaId);
+ }
+ };
+
+ const isMuted = canElementHaveAudio(element) && element.muted === true;
+
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ className={`absolute top-0 h-full select-none ${
+ isBeingDragged ? "z-30" : "z-10"
+ }`}
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ transform:
+ isBeingDragged && dragState.isDragging
+ ? `translate3d(0, ${dragOffsetY}px, 0)`
+ : undefined,
+ }}
+ >
+ <ElementInner
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ isBeingDragged={isBeingDragged}
+ hasAudio={hasAudio}
+ isMuted={isMuted}
+ mediaAssets={mediaAssets}
+ onElementClick={onElementClick}
+ onElementMouseDown={onElementMouseDown}
+ handleResizeStart={handleResizeStart}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="z-200 w-64">
+ <ActionMenuItem
+ action="split"
+ icon={<HugeiconsIcon icon={ScissorIcon} />}
+ >
+ Split
+ </ActionMenuItem>
+ <CopyMenuItem />
+ {canElementHaveAudio(element) && hasAudio && (
+ <MuteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canElementBeHidden(element) && (
+ <VisibilityMenuItem
+ element={element}
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {selectedElements.length === 1 && (
+ <ActionMenuItem
+ action="duplicate-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Duplicate
+ </ActionMenuItem>
+ )}
+ {selectedElements.length === 1 && hasMediaId(element) && (
+ <>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Search01Icon} />}
+ onClick={(event) => handleRevealInMedia({ event })}
+ >
+ Reveal media
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Exchange01Icon} />}
+ disabled
+ >
+ Replace media
+ </ContextMenuItem>
+ </>
+ )}
+ <ContextMenuSeparator />
+ <DeleteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ elementType={element.type}
+ selectedCount={selectedElements.length}
+ />
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+}
+
+function ElementInner({
+ element,
+ track,
+ isSelected,
+ isBeingDragged,
+ hasAudio,
+ isMuted,
+ mediaAssets,
+ onElementClick,
+ onElementMouseDown,
+ handleResizeStart,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ isBeingDragged: boolean;
+ hasAudio: boolean;
+ isMuted: boolean;
+ mediaAssets: MediaAsset[];
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ onElementMouseDown: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ return (
+ <div
+ className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
+ {
+ type: track.type,
+ },
+ )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
+ >
+ <button
+ type="button"
+ className="absolute inset-0 size-full cursor-pointer"
+ onClick={(e) => onElementClick(e, element)}
+ onMouseDown={(e) => onElementMouseDown(e, element)}
+ >
+ <div className="absolute inset-0 flex h-full items-center">
+ <ElementContent
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ mediaAssets={mediaAssets}
+ />
+ </div>
+
+ {(hasAudio
+ ? isMuted
+ : canElementBeHidden(element) && element.hidden) && (
+ <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
+ {hasAudio ? (
+ <HugeiconsIcon
+ icon={VolumeHighIcon}
+ className="size-6 text-white"
+ />
+ ) : (
+ <HugeiconsIcon
+ icon={VolumeOffIcon}
+ className="size-6 text-white"
+ />
+ )}
+ </div>
+ )}
+ </button>
+
+ {isSelected && (
+ <>
+ <ResizeHandle
+ side="left"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ <ResizeHandle
+ side="right"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ </>
+ )}
+ </div>
+ );
+}
+
+function ResizeHandle({
+ side,
+ elementId,
+ handleResizeStart,
+}: {
+ side: "left" | "right";
+ elementId: string;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ const isLeft = side === "left";
+ return (
+ <button
+ type="button"
+ className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
+ onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
+ aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
+ >
+ <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
+ </button>
+ );
+}
+
+function ElementContent({
+ element,
+ track,
+ isSelected,
+ mediaAssets,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ mediaAssets: MediaAsset[];
+}) {
+ if (element.type === "text") {
+ return (
+ <div className="flex size-full items-center justify-start pl-2">
+ <span className="truncate text-xs text-white">{element.content}</span>
+ </div>
+ );
+ }
+
+ if (element.type === "sticker") {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
+ alt={element.name}
+ className="size-5 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+ }
+
+ if (element.type === "audio") {
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+
+ const audioUrl =
+ element.sourceType === "library"
+ ? element.sourceUrl
+ : mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
+
+ if (audioBuffer || audioUrl) {
+ return (
+ <div className="flex size-full items-center gap-2">
+ <div className="min-w-0 flex-1">
+ <AudioWaveform
+ audioBuffer={audioBuffer}
+ audioUrl={audioUrl}
+ height={24}
+ className="w-full"
+ />
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
+
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ if (!mediaAsset) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
+
+ if (
+ mediaAsset.type === "image" ||
+ (mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
+ ) {
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * (16 / 9);
+ const imageUrl =
+ mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
+
+ return (
+ <div className="flex size-full items-center justify-center">
+ <div
+ className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
+ >
+ <div
+ className="absolute right-0 left-0"
+ style={{
+ backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ top: isSelected ? "0.25rem" : "0rem",
+ bottom: isSelected ? "0.25rem" : "0rem",
+ }}
+ />
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <span className="text-foreground/80 truncate text-xs">{element.name}</span>
+ );
+}
+
+function CopyMenuItem() {
+ return (
+ <ActionMenuItem
+ action="copy-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Copy
+ </ActionMenuItem>
+ );
+}
+
+function MuteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ isMuted,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ isMuted: boolean;
+}) {
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={VolumeMute02Icon} />;
+ }
+ return isMuted ? (
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ ) : (
+ <HugeiconsIcon icon={VolumeOffIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
+ {isMuted ? "Unmute" : "Mute"}
+ </ActionMenuItem>
+ );
+}
+
+function VisibilityMenuItem({
+ element,
+ isMultipleSelected,
+ isCurrentElementSelected,
+}: {
+ element: TimelineElementType;
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+}) {
+ const isHidden = canElementBeHidden(element) && element.hidden;
+
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={ViewOffSlashIcon} />;
+ }
+ return isHidden ? (
+ <HugeiconsIcon icon={ViewIcon} />
+ ) : (
+ <HugeiconsIcon icon={ViewOffSlashIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem
+ action="toggle-elements-visibility-selected"
+ icon={getIcon()}
+ >
+ {isHidden ? "Show" : "Hide"}
+ </ActionMenuItem>
+ );
+}
+
+function DeleteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ elementType,
+ selectedCount,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ elementType: TimelineElementType["type"];
+ selectedCount: number;
+}) {
+ return (
+ <ActionMenuItem
+ action="delete-selected"
+ variant="destructive"
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ >
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedCount} elements`
+ : `Delete ${elementType === "text" ? "text" : "clip"}`}
+ </ActionMenuItem>
+ );
+}
+
+function ActionMenuItem({
+ action,
+ children,
+ ...props
+}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
+ action: TAction;
+}) {
+ return (
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ invokeAction(action);
+ }}
+ textRight={getDisplayShortcut(action)}
+ {...props}
+ >
+ {children}
+ </ContextMenuItem>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
new file mode 100644
index 00000000..8af35dc9
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useRef } from "react";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
+import { useEditor } from "@/hooks/use-editor";
+
+interface TimelinePlayheadProps {
+ zoomLevel: number;
+ rulerRef: React.RefObject<HTMLDivElement | null>;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ timelineRef: React.RefObject<HTMLDivElement | null>;
+ playheadRef?: React.RefObject<HTMLDivElement | null>;
+ isSnappingToPlayhead?: boolean;
+}
+
+export function TimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ timelineRef,
+ playheadRef: externalPlayheadRef,
+ isSnappingToPlayhead = false,
+}: TimelinePlayheadProps) {
+ const editor = useEditor();
+ const duration = editor.timeline.getTotalDuration();
+ const internalPlayheadRef = useRef<HTMLDivElement>(null);
+ const playheadRef = externalPlayheadRef || internalPlayheadRef;
+
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ const timelineContainerHeight =
+ tracksScrollRef.current?.clientHeight ??
+ timelineRef.current?.clientHeight ??
+ 400;
+ const totalHeight = Math.max(0, timelineContainerHeight - 4);
+
+ const timelinePosition =
+ playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const leftPosition = timelinePosition;
+
+ const handlePlayheadKeyDown = (
+ event: React.KeyboardEvent<HTMLDivElement>,
+ ) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
+
+ event.preventDefault();
+ const step = 1 / Math.max(1, editor.project.getActive().settings.fps);
+ const direction = event.key === "ArrowRight" ? 1 : -1;
+ const nextTime = Math.max(
+ 0,
+ Math.min(duration, playheadPosition + direction * step),
+ );
+
+ editor.playback.seek({ time: nextTime });
+ };
+
+ return (
+ <div
+ ref={playheadRef}
+ role="slider"
+ aria-label="Timeline playhead"
+ aria-valuemin={0}
+ aria-valuemax={duration}
+ aria-valuenow={playheadPosition}
+ tabIndex={0}
+ className="pointer-events-auto absolute z-60"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: "2px",
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ onKeyDown={handlePlayheadKeyDown}
+ >
+ <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
+
+ <div
+ className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
new file mode 100644
index 00000000..b0261aa2
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
@@ -0,0 +1,126 @@
+import { type JSX, useLayoutEffect, useRef } from "react";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DEFAULT_FPS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
+import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
+import { TimelineTick } from "./timeline-tick";
+
+interface TimelineRulerProps {
+ zoomLevel: number;
+ dynamicTimelineWidth: number;
+ rulerRef: React.Ref<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLElement | null>;
+ handleWheel: (e: React.WheelEvent) => void;
+ handleTimelineContentClick: (e: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
+ handleRulerMouseDown: (e: React.MouseEvent) => void;
+}
+
+export function TimelineRuler({
+ zoomLevel,
+ dynamicTimelineWidth,
+ rulerRef,
+ tracksScrollRef,
+ handleWheel,
+ handleTimelineContentClick,
+ handleRulerTrackingMouseDown,
+ handleRulerMouseDown,
+}: TimelineRulerProps) {
+ const editor = useEditor();
+ const duration = editor.timeline.getTotalDuration();
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
+ const effectiveDuration = Math.max(duration, visibleDuration);
+ const project = editor.project.getActive();
+ const fps = project?.settings.fps ?? DEFAULT_FPS;
+ const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
+ zoomLevel,
+ fps,
+ });
+ const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
+
+ const { scrollLeft, viewportWidth } = useScrollPosition({
+ scrollRef: tracksScrollRef,
+ });
+
+ /**
+ * widens the virtualization buffer during zoom transitions.
+ * useScrollPosition lags one frame behind the scroll adjustment
+ * that useLayoutEffect applies after a zoom change.
+ */
+ const prevZoomRef = useRef(zoomLevel);
+ const isZoomTransition = zoomLevel !== prevZoomRef.current;
+ const bufferPx = isZoomTransition
+ ? Math.max(200, (scrollLeft + viewportWidth) * 0.15)
+ : 200;
+
+ useLayoutEffect(() => {
+ prevZoomRef.current = zoomLevel;
+ }, [zoomLevel]);
+
+ const visibleStartTime = Math.max(
+ 0,
+ (scrollLeft - bufferPx) / pixelsPerSecond,
+ );
+ const visibleEndTime =
+ (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
+
+ const startTickIndex = Math.max(
+ 0,
+ Math.floor(visibleStartTime / tickIntervalSeconds),
+ );
+ const endTickIndex = Math.min(
+ tickCount - 1,
+ Math.ceil(visibleEndTime / tickIntervalSeconds),
+ );
+
+ const timelineTicks: Array<JSX.Element> = [];
+ for (
+ let tickIndex = startTickIndex;
+ tickIndex <= endTickIndex;
+ tickIndex += 1
+ ) {
+ const time = tickIndex * tickIntervalSeconds;
+ if (time > effectiveDuration) break;
+
+ const showLabel = shouldShowLabel({ time, labelIntervalSeconds });
+ timelineTicks.push(
+ <TimelineTick
+ key={tickIndex}
+ time={time}
+ zoomLevel={zoomLevel}
+ fps={fps}
+ showLabel={showLabel}
+ />,
+ );
+ }
+
+ return (
+ <div
+ role="slider"
+ tabIndex={0}
+ aria-label="Timeline ruler"
+ aria-valuemin={0}
+ aria-valuemax={effectiveDuration}
+ aria-valuenow={0}
+ className="relative h-4 flex-1 overflow-x-visible"
+ onWheel={handleWheel}
+ onClick={handleTimelineContentClick}
+ onMouseDown={handleRulerTrackingMouseDown}
+ onKeyDown={() => {}}
+ >
+ <div
+ role="none"
+ ref={rulerRef}
+ className="relative h-4 cursor-default select-none"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ onMouseDown={handleRulerMouseDown}
+ >
+ {timelineTicks}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
new file mode 100644
index 00000000..9402d25e
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
+
+interface TimelineTickProps {
+ time: number;
+ zoomLevel: number;
+ fps: number;
+ showLabel: boolean;
+}
+
+export function TimelineTick({
+ time,
+ zoomLevel,
+ fps,
+ showLabel,
+}: TimelineTickProps) {
+ const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ if (showLabel) {
+ const label = formatRulerLabel({ timeInSeconds: time, fps });
+ return (
+ <span
+ className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none"
+ style={{ left: `${leftPosition}px` }}
+ >
+ {label}
+ </span>
+ );
+ }
+
+ return (
+ <div
+ className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l"
+ style={{ left: `${leftPosition}px` }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
new file mode 100644
index 00000000..0b02c63f
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -0,0 +1,285 @@
+import { useEditor } from "@/hooks/use-editor";
+import {
+ TooltipProvider,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import { SplitSquareHorizontal } from "lucide-react";
+import {
+ SplitButton,
+ SplitButtonLeft,
+ SplitButtonRight,
+ SplitButtonSeparator,
+} from "@/components/ui/split-button";
+import { Slider } from "@/components/ui/slider";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
+import { ScenesView } from "../../scenes-view";
+import { type TAction, invokeAction } from "@/lib/actions";
+import { cn } from "@/utils/ui";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Bookmark02Icon,
+ Delete02Icon,
+ SnowIcon,
+ ScissorIcon,
+ MagnetIcon,
+ Link04Icon,
+ SearchAddIcon,
+ SearchMinusIcon,
+ Copy01Icon,
+ AlignLeftIcon,
+ AlignRightIcon,
+ Layers01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function TimelineToolbar({
+ zoomLevel,
+ minZoom,
+ setZoomLevel,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+ setZoomLevel: ({ zoom }: { zoom: number }) => void;
+}) {
+ const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
+ const newZoomLevel =
+ direction === "in"
+ ? Math.min(
+ TIMELINE_CONSTANTS.ZOOM_MAX,
+ zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
+ )
+ : Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
+ setZoomLevel({ zoom: newZoomLevel });
+ };
+
+ return (
+ <ScrollArea className="scrollbar-hidden">
+ <div className="flex h-10 items-center justify-between border-b px-2 py-1">
+ <ToolbarLeftSection />
+
+ <SceneSelector />
+
+ <ToolbarRightSection
+ zoomLevel={zoomLevel}
+ minZoom={minZoom}
+ onZoomChange={(zoom) => setZoomLevel({ zoom })}
+ onZoom={handleZoom}
+ />
+ </div>
+ </ScrollArea>
+ );
+}
+
+function ToolbarLeftSection() {
+ const editor = useEditor();
+ const currentTime = editor.playback.getCurrentTime();
+ const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
+
+ const handleAction = ({
+ action,
+ event,
+ }: {
+ action: TAction;
+ event: React.MouseEvent;
+ }) => {
+ event.stopPropagation();
+ invokeAction(action);
+ };
+
+ return (
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={ScissorIcon} />}
+ tooltip="Split element"
+ onClick={({ event }) => handleAction({ action: "split", event })}
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={AlignLeftIcon} />}
+ tooltip="Split left"
+ onClick={({ event }) => handleAction({ action: "split-left", event })}
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={AlignRightIcon} />}
+ tooltip="Split right"
+ onClick={({ event }) =>
+ handleAction({ action: "split-right", event })
+ }
+ />
+
+ <ToolbarButton
+ icon={<SplitSquareHorizontal />}
+ tooltip="Coming soon" /* separate audio */
+ disabled={true}
+ onClick={({ event: _event }) => {}}
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ tooltip="Duplicate element"
+ onClick={({ event }) =>
+ handleAction({ action: "duplicate-selected", event })
+ }
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={SnowIcon} />}
+ tooltip="Coming soon" /* freeze frame */
+ disabled={true}
+ onClick={({ event: _event }) => {}}
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ tooltip="Delete element"
+ onClick={({ event }) =>
+ handleAction({ action: "delete-selected", event })
+ }
+ />
+
+ <div className="bg-border mx-1 h-6 w-px" />
+
+ <Tooltip>
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Bookmark02Icon} />}
+ isActive={currentBookmarked}
+ tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
+ onClick={({ event }) =>
+ handleAction({ action: "toggle-bookmark", event })
+ }
+ />
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+}
+
+function SceneSelector() {
+ const editor = useEditor();
+ const currentScene = editor.scenes.getActiveScene();
+
+ return (
+ <div>
+ <SplitButton className="border-foreground/10 border">
+ <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
+ <SplitButtonSeparator />
+ <ScenesView>
+ <SplitButtonRight onClick={() => {}} type="button">
+ <HugeiconsIcon icon={Layers01Icon} className="size-4" />
+ </SplitButtonRight>
+ </ScenesView>
+ </SplitButton>
+ </div>
+ );
+}
+
+function ToolbarRightSection({
+ zoomLevel,
+ minZoom,
+ onZoomChange,
+ onZoom,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+ onZoomChange: (zoom: number) => void;
+ onZoom: (options: { direction: "in" | "out" }) => void;
+}) {
+ const {
+ snappingEnabled,
+ rippleEditingEnabled,
+ toggleSnapping,
+ toggleRippleEditing,
+ } = useTimelineStore();
+
+ return (
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={MagnetIcon} />}
+ isActive={snappingEnabled}
+ tooltip="Auto snapping"
+ onClick={() => toggleSnapping()}
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
+ isActive={rippleEditingEnabled}
+ tooltip="Ripple editing"
+ onClick={() => toggleRippleEditing()}
+ />
+ </TooltipProvider>
+
+ <div className="bg-border mx-1 h-6 w-px" />
+
+ <div className="flex items-center gap-1">
+ <Button
+ variant="text"
+ size="icon"
+ type="button"
+ onClick={() => onZoom({ direction: "out" })}
+ >
+ <HugeiconsIcon icon={SearchMinusIcon} />
+ </Button>
+ <Slider
+ className="w-28"
+ value={[zoomToSlider({ zoomLevel, minZoom })]}
+ onValueChange={(values) =>
+ onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
+ }
+ min={0}
+ max={1}
+ step={0.005}
+ />
+ <Button
+ variant="text"
+ size="icon"
+ type="button"
+ onClick={() => onZoom({ direction: "in" })}
+ >
+ <HugeiconsIcon icon={SearchAddIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function ToolbarButton({
+ icon,
+ tooltip,
+ onClick,
+ disabled,
+ isActive,
+}: {
+ icon: React.ReactNode;
+ tooltip: string;
+ onClick: ({ event }: { event: React.MouseEvent }) => void;
+ disabled?: boolean;
+ isActive?: boolean;
+}) {
+ return (
+ <Tooltip delayDuration={200}>
+ <TooltipTrigger asChild>
+ <Button
+ variant={isActive ? "secondary" : "text"}
+ size="icon"
+ type="button"
+ onClick={(event) => onClick({ event })}
+ className={cn(
+ "rounded-sm",
+ disabled ? "cursor-not-allowed opacity-50" : "",
+ )}
+ >
+ {icon}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>{tooltip}</TooltipContent>
+ </Tooltip>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
new file mode 100644
index 00000000..b7b06d5b
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { TimelineElement } from "./timeline-element";
+import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineElement as TimelineElementType } from "@/types/timeline";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import type { ElementDragState } from "@/types/timeline";
+import { useEditor } from "@/hooks/use-editor";
+
+interface TimelineTrackContentProps {
+ track: TimelineTrack;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ lastMouseXRef: React.RefObject<number>;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onElementClick: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onTrackMouseDown?: (event: React.MouseEvent) => void;
+ onTrackClick?: (event: React.MouseEvent) => void;
+ shouldIgnoreClick?: () => boolean;
+}
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+ dragState,
+ rulerScrollRef,
+ tracksScrollRef,
+ lastMouseXRef,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ onTrackClick,
+ shouldIgnoreClick,
+}: TimelineTrackContentProps) {
+ const editor = useEditor();
+ const { isElementSelected, clearElementSelection } = useElementSelection();
+
+ const duration = editor.timeline.getTotalDuration();
+
+ useEdgeAutoScroll({
+ isActive: dragState.isDragging,
+ getMouseClientX: () => lastMouseXRef.current ?? 0,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ });
+
+ return (
+ <button
+ className="size-full"
+ onClick={(event) => {
+ if (shouldIgnoreClick?.()) return;
+ clearElementSelection();
+ onTrackClick?.(event);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ type="button"
+ >
+ <div className="relative h-full min-w-full">
+ {track.elements.length === 0 ? (
+ <div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
+ ) : (
+ track.elements.map((element) => {
+ const isSelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+
+ return (
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onSnapPointChange={onSnapPointChange}
+ onResizeStateChange={onResizeStateChange}
+ onElementMouseDown={(event, element) =>
+ onElementMouseDown({ event, element, track })
+ }
+ onElementClick={(event, element) =>
+ onElementClick({ event, element, track })
+ }
+ dragState={dragState}
+ />
+ );
+ })
+ )}
+ </div>
+ </button>
+ );
+}