summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editable-timecode.tsx26
-rw-r--r--apps/web/src/components/editor/dialogs/project-info-dialog.tsx2
-rw-r--r--apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx2
-rw-r--r--apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx41
-rw-r--r--apps/web/src/components/editor/editor-header.tsx7
-rw-r--r--apps/web/src/components/editor/export-button.tsx63
-rw-r--r--apps/web/src/components/editor/mobile-gate.tsx6
-rw-r--r--apps/web/src/components/editor/panels/assets/drag-overlay.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx17
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx15
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx153
-rw-r--r--apps/web/src/components/editor/panels/assets/views/base-panel.tsx (renamed from apps/web/src/components/editor/panels/assets/views/base-view.tsx)11
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx141
-rw-r--r--apps/web/src/components/editor/panels/assets/views/effects.tsx15
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx332
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx168
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/background.tsx227
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx369
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx538
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx12
-rw-r--r--apps/web/src/components/editor/panels/preview/context-menu.tsx39
-rw-r--r--apps/web/src/components/editor/panels/preview/guide-overlay.tsx32
-rw-r--r--apps/web/src/components/editor/panels/preview/guide-popover.tsx100
-rw-r--r--apps/web/src/components/editor/panels/preview/handle-primitives.tsx332
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx266
-rw-r--r--apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx27
-rw-r--r--apps/web/src/components/editor/panels/preview/mask-handles.tsx238
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx109
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-viewport.tsx576
-rw-r--r--apps/web/src/components/editor/panels/preview/snap-guides.tsx30
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx149
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx169
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx273
-rw-r--r--apps/web/src/components/editor/panels/properties/audio-properties.tsx3
-rw-r--r--apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx215
-rw-r--r--apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx (renamed from apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx)0
-rw-r--r--apps/web/src/components/editor/panels/properties/components/property-param-field.tsx194
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-param-field.tsx152
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-properties.tsx58
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts4
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts26
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts148
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts190
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx141
-rw-r--r--apps/web/src/components/editor/panels/properties/registry.tsx303
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx382
-rw-r--r--apps/web/src/components/editor/panels/properties/stores/properties-store.ts18
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx109
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx (renamed from apps/web/src/components/editor/panels/properties/sections/blending.tsx)196
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx309
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx238
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx660
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx137
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx (renamed from apps/web/src/components/editor/panels/properties/text-properties.tsx)136
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx424
-rw-r--r--apps/web/src/components/editor/panels/properties/video-properties.tsx25
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx271
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx27
-rw-r--r--apps/web/src/components/editor/panels/timeline/drag-line.tsx5
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx885
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx10
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx470
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx38
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx26
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx4
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx16
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx62
-rw-r--r--apps/web/src/components/editor/selection-box.tsx50
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx27
-rw-r--r--apps/web/src/components/section.tsx (renamed from apps/web/src/components/editor/panels/properties/section.tsx)131
-rw-r--r--apps/web/src/components/ui/button.tsx2
-rw-r--r--apps/web/src/components/ui/color-picker.tsx13
-rw-r--r--apps/web/src/components/ui/context-menu.tsx14
-rw-r--r--apps/web/src/components/ui/dialog.tsx16
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx23
-rw-r--r--apps/web/src/components/ui/font-picker.tsx107
-rw-r--r--apps/web/src/components/ui/input-with-back.tsx85
-rw-r--r--apps/web/src/components/ui/input.tsx7
-rw-r--r--apps/web/src/components/ui/number-field.tsx223
-rw-r--r--apps/web/src/components/ui/popover.tsx20
-rw-r--r--apps/web/src/components/ui/select.tsx16
-rw-r--r--apps/web/src/components/ui/sheet.tsx17
-rw-r--r--apps/web/src/components/ui/sonner.tsx2
-rw-r--r--apps/web/src/components/ui/tabs.tsx92
-rw-r--r--apps/web/src/components/ui/tooltip.tsx4
-rw-r--r--apps/web/src/components/ui/use-overlay-open-change.ts78
88 files changed, 7717 insertions, 3583 deletions
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx
index 61f23372..40390f07 100644
--- a/apps/web/src/components/editable-timecode.tsx
+++ b/apps/web/src/components/editable-timecode.tsx
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time";
-import type { TTimeCode } from "@/types/time";
+import type { TTimeCode } from "@/lib/time";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
@@ -66,16 +66,13 @@ export function EditableTimecode({
enterPressedRef.current = false;
};
- const handleKeyDown = ({
- key,
- preventDefault,
- }: React.KeyboardEvent<HTMLInputElement>) => {
- if (key === "Enter") {
- preventDefault();
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
enterPressedRef.current = true;
applyEdit();
- } else if (key === "Escape") {
- preventDefault();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
cancelEditing();
}
};
@@ -93,14 +90,13 @@ export function EditableTimecode({
}
};
- const handleDisplayKeyDown = ({
- key,
- preventDefault,
- }: React.KeyboardEvent<HTMLButtonElement>) => {
+ const handleDisplayKeyDown = (
+ event: React.KeyboardEvent<HTMLButtonElement>,
+ ) => {
if (disabled) return;
- if (key === "Enter" || key === " ") {
- preventDefault();
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
startEditing();
}
};
diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
index 7b736b23..42f99c79 100644
--- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
+++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
@@ -6,7 +6,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import type { TProjectMetadata } from "@/types/project";
+import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
import { Button } from "@/components/ui/button";
diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
index 565fe705..9a353a1c 100644
--- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
+++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
@@ -110,7 +110,7 @@ export function ShortcutsDialog({
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
- <DialogBody className="scrollbar-thin flex-grow overflow-y-auto">
+ <DialogBody className="scrollbar-thin grow overflow-y-auto">
<div className="flex flex-col gap-6">
{categories.map((category) => (
<div key={category} className="flex flex-col gap-1">
diff --git a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx
new file mode 100644
index 00000000..d391928c
--- /dev/null
+++ b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { useStoragePersistence } from "@/hooks/use-storage-persistence";
+
+export function StoragePersistenceDialog() {
+ const { showDialog, onConfirm, onDismiss } = useStoragePersistence();
+
+ return (
+ <Dialog open={showDialog} onOpenChange={(open) => !open && onDismiss()}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Don't lose your projects</DialogTitle>
+ </DialogHeader>
+ <DialogBody>
+ <p className="text-base text-muted-foreground">
+ Your browser can automatically delete your projects when storage
+ runs low.
+ </p>
+ <p className="text-base text-muted-foreground">
+ Allow OpenCut to protect them?
+ </p>
+ </DialogBody>
+ <DialogFooter>
+ <Button variant="outline" onClick={onDismiss}>
+ Not now
+ </Button>
+ <Button onClick={onConfirm}>Allow</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index 2dea73e2..8dd730b5 100644
--- a/apps/web/src/components/editor/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -47,7 +47,7 @@ function ProjectDropdown() {
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const activeProject = useEditor((e) => e.project.getActive());
const handleExit = async () => {
if (isExiting) return;
@@ -136,7 +136,7 @@ function ProjectDropdown() {
<DropdownMenuSeparator />
- <DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}>
+ <DropdownMenuItem asChild icon={<FaDiscord className="size-4!" />}>
<Link
href={SOCIAL_LINKS.discord}
target="_blank"
@@ -169,7 +169,7 @@ function ProjectDropdown() {
function EditableProjectName() {
const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const activeProject = useEditor((e) => e.project.getActive());
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const originalNameRef = useRef("");
@@ -219,6 +219,7 @@ function EditableProjectName() {
event.preventDefault();
if (inputRef.current) {
inputRef.current.value = originalNameRef.current;
+ inputRef.current.setSelectionRange(0, 0);
}
setIsEditing(false);
inputRef.current?.blur();
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index ddf8308d..ec39e7e6 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -14,20 +14,24 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/utils/ui";
-import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export";
+import {
+ getExportMimeType,
+ getExportFileExtension,
+ downloadBuffer,
+} from "@/lib/export";
import { Check, Copy, Download, RotateCcw } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
type ExportFormat,
type ExportQuality,
-} from "@/types/export";
+} from "@/lib/export";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
-} from "@/components/editor/panels/properties/section";
+} from "@/components/section";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
@@ -42,8 +46,8 @@ function isExportQuality(value: string): value is ExportQuality {
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const editor = useEditor();
-
- const hasProject = !!editor.project.getActiveOrNull();
+ const activeProject = useEditor((e) => e.project.getActiveOrNull());
+ const hasProject = !!activeProject;
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
if (!open) {
@@ -54,7 +58,10 @@ export function ExportButton() {
};
return (
- <Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}>
+ <Popover
+ open={isExportPopoverOpen}
+ onOpenChange={(open) => handlePopoverOpenChange({ open })}
+ >
<PopoverTrigger asChild>
<button
type="button"
@@ -91,9 +98,9 @@ function ExportPopover({
onOpenChange: (open: boolean) => void;
}) {
const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { isExporting, progress, result: exportResult } =
- editor.project.getExportState();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const exportState = useEditor((e) => e.project.getExportState());
+ const { isExporting, progress, result: exportResult } = exportState;
const [format, setFormat] = useState<ExportFormat>(
DEFAULT_EXPORT_OPTIONS.format,
);
@@ -109,10 +116,10 @@ function ExportPopover({
const result = await editor.project.export({
options: {
- format,
- quality,
- fps: activeProject.settings.fps,
- includeAudio: shouldIncludeAudio,
+ format,
+ quality,
+ fps: activeProject.settings.fps,
+ includeAudio: shouldIncludeAudio,
},
});
@@ -156,7 +163,11 @@ function ExportPopover({
{!isExporting && (
<>
<div className="flex flex-col">
- <Section collapsible defaultOpen={false} showTopBorder={false}>
+ <Section
+ collapsible
+ defaultOpen={false}
+ showTopBorder={false}
+ >
<SectionHeader>
<SectionTitle>Format</SectionTitle>
</SectionHeader>
@@ -228,10 +239,10 @@ function ExportPopover({
<div className="flex items-center space-x-2">
<Checkbox
id="include-audio"
- checked={shouldIncludeAudio}
- onCheckedChange={(checked) =>
- setShouldIncludeAudio(!!checked)
- }
+ checked={shouldIncludeAudio}
+ onCheckedChange={(checked) =>
+ setShouldIncludeAudio(!!checked)
+ }
/>
<Label htmlFor="include-audio">
Include audio in export
@@ -252,15 +263,15 @@ function ExportPopover({
{isExporting && (
<div className="space-y-4 p-3">
- <div className="flex flex-col gap-2">
- <div className="flex items-center justify-between text-center">
- <p className="text-muted-foreground text-sm">
- {Math.round(progress * 100)}%
- </p>
- <p className="text-muted-foreground text-sm">100%</p>
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center justify-between text-center">
+ <p className="text-muted-foreground text-sm">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-muted-foreground text-sm">100%</p>
+ </div>
+ <Progress value={progress * 100} className="w-full" />
</div>
- <Progress value={progress * 100} className="w-full" />
- </div>
<Button
variant="outline"
diff --git a/apps/web/src/components/editor/mobile-gate.tsx b/apps/web/src/components/editor/mobile-gate.tsx
index 8fecf3cb..6483e438 100644
--- a/apps/web/src/components/editor/mobile-gate.tsx
+++ b/apps/web/src/components/editor/mobile-gate.tsx
@@ -36,7 +36,7 @@ export function MobileGate({ children }: MobileGateProps) {
};
return (
- <div className="dark bg-background relative flex h-screen w-screen flex-col overflow-hidden">
+ <div className="bg-background relative flex h-screen w-screen flex-col overflow-hidden">
<Button
variant="text"
className="absolute top-6 left-6 flex items-center gap-1 text-muted-foreground"
@@ -58,9 +58,7 @@ export function MobileGate({ children }: MobileGateProps) {
</p>
</div>
<div className="flex items-center gap-3">
- <Button onClick={handleContinue}>
- Take a look anyway
- </Button>
+ <Button onClick={handleContinue}>Take a look anyway</Button>
<Button variant="ghost" asChild>
<Link href="/roadmap" className="flex items-center gap-1">
Roadmap
diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
index c6b0861c..9d9ae903 100644
--- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
+++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
@@ -29,7 +29,7 @@ export function MediaDragOverlay({
return (
<button
- className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
+ className="bg-foreground/5 hover:bg-foreground/10 flex flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
type="button"
disabled={isProcessing || !onClick}
onClick={(event) => handleClick({ event })}
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 fae74e84..8c9acce8 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import { clearDragData, setDragData } from "@/lib/drag-data";
-import type { TimelineDragData } from "@/types/drag";
+import type { TimelineDragData } from "@/lib/timeline/drag";
import { cn } from "@/utils/ui";
export interface DraggableItemProps {
@@ -29,7 +29,6 @@ export interface DraggableItemProps {
isRounded?: boolean;
variant?: "card" | "compact";
isDraggable?: boolean;
- isHighlighted?: boolean;
}
export function DraggableItem({
@@ -46,13 +45,11 @@ export function DraggableItem({
isRounded = true,
variant = "card",
isDraggable = true,
- isHighlighted = false,
}: DraggableItemProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
const editor = useEditor();
- const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`;
const handleAddToTimeline = () => {
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
@@ -102,9 +99,8 @@ export function DraggableItem({
>
<div
className={cn(
- "relative flex h-auto w-full cursor-default flex-col gap-1 p-1",
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
className,
- isHighlighted && highlightClassName,
)}
>
<AspectRatio
@@ -144,15 +140,12 @@ export function DraggableItem({
) : (
<div
ref={dragRef}
- className={cn(
- "group relative w-full",
- isHighlighted && highlightClassName,
- )}
+ className={cn("group relative w-full", containerClassName)}
>
<button
type="button"
className={cn(
- "flex h-8 w-full cursor-default items-center gap-3 px-1",
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
className,
)}
@@ -160,7 +153,7 @@ export function DraggableItem({
onDragStart={isDraggable ? handleDragStart : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
- <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
{preview}
</div>
<span className="w-full flex-1 truncate text-sm text-left">
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index 61777a7c..a4e072f1 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -49,7 +49,7 @@ export function TabBar() {
<div className="relative flex">
<div
ref={scrollRef}
- className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto"
+ className="scrollbar-hidden relative flex size-full p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto"
>
{TAB_KEYS.map((tabKey) => {
const tab = tabs[tabKey];
@@ -57,12 +57,13 @@ export function TabBar() {
<Tooltip key={tabKey} delayDuration={10}>
<TooltipTrigger asChild>
<Button
- variant={activeTab === tabKey ? "secondary" : "text"}
+ variant={activeTab === tabKey ? "secondary" : "ghost"}
+ size="icon"
aria-label={tab.label}
className={cn(
- "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
- activeTab !== tabKey &&
- "border border-transparent text-muted-foreground",
+ "shrink-0",
+ "h-8 w-8",
+ activeTab !== tabKey && "text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
>
@@ -102,8 +103,8 @@ function FadeOverlay({
className={cn(
"pointer-events-none absolute right-0 left-0 h-6",
direction === "top" && show
- ? "from-background top-0 bg-gradient-to-b to-transparent"
- : "from-background bottom-0 bg-gradient-to-t to-transparent",
+ ? "from-background top-0 bg-linear-to-b to-transparent"
+ : "from-background bottom-0 bg-linear-to-t to-transparent",
)}
/>
);
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 3b35c022..e17e4d4a 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -2,11 +2,11 @@
import Image from "next/image";
import { useMemo, useState } from "react";
-import { toast } from "sonner";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
import {
ContextMenu,
ContextMenuContent,
@@ -28,8 +28,15 @@ import {
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
-import { useRevealItem } from "@/hooks/use-reveal-item";
+import { invokeAction } from "@/lib/actions";
import { processMediaAssets } from "@/lib/media/processing";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import {
+ SelectableItem,
+ SelectableSurface,
+ useSelection,
+ useSelectionScope,
+} from "@/lib/selection";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import {
type MediaSortKey,
@@ -37,7 +44,8 @@ import {
type MediaViewMode,
useAssetsPanelStore,
} from "@/stores/assets-panel-store";
-import type { MediaAsset } from "@/types/assets";
+import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
@@ -52,8 +60,8 @@ import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
export function MediaView() {
const editor = useEditor();
- const mediaFiles = editor.media.getAssets();
- const activeProject = editor.project.getActive();
+ const mediaFiles = useEditor((e) => e.media.getAssets());
+ const activeProject = useEditor((e) => e.project.getActive());
const {
mediaViewMode,
@@ -64,15 +72,11 @@ export function MediaView() {
mediaSortOrder,
setMediaSort,
} = useAssetsPanelStore();
- const { highlightedId, registerElement } = useRevealItem(
- highlightMediaId,
- clearHighlight,
- );
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
- const processFiles = async ({ files }: { files: FileList }) => {
+ const processFiles = async ({ files }: { files: File[] }) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
@@ -82,20 +86,28 @@ export function MediaView() {
setIsProcessing(true);
setProgress(0);
try {
- const processedAssets = await processMediaAssets({
- files,
- onProgress: (progress: { progress: number }) =>
- setProgress(progress.progress),
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({
+ files,
+ onProgress: (progress: { progress: number }) =>
+ setProgress(progress.progress),
+ });
+ for (const asset of processedAssets) {
+ await editor.media.addMediaAsset({
+ projectId: activeProject.metadata.id,
+ asset,
+ });
+ }
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
});
- for (const asset of processedAssets) {
- await editor.media.addMediaAsset({
- projectId: activeProject.metadata.id,
- asset,
- });
- }
} catch (error) {
console.error("Error processing files:", error);
- toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
@@ -109,23 +121,18 @@ export function MediaView() {
onFilesSelected: (files) => processFiles({ files }),
});
- const handleRemove = async ({
+ const handleRemove = ({
event,
- id,
+ ids,
}: {
event: React.MouseEvent;
- id: string;
+ ids: string[];
}) => {
event.stopPropagation();
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- await editor.media.removeMediaAsset({
+ invokeAction("remove-media-assets", {
projectId: activeProject.metadata.id,
- id,
+ assetIds: ids,
});
};
@@ -172,6 +179,9 @@ export function MediaView() {
return filtered;
}, [mediaFiles, mediaSortBy, mediaSortOrder]);
+ const orderedMediaIds = useMemo(() => {
+ return filteredMediaItems.map((item) => item.id);
+ }, [filteredMediaItems]);
return (
<>
@@ -191,6 +201,7 @@ export function MediaView() {
/>
}
className={cn(isDragOver && "bg-accent/30")}
+ contentClassName="h-full"
{...dragProps}
>
{isDragOver || filteredMediaItems.length === 0 ? (
@@ -201,29 +212,38 @@ export function MediaView() {
onClick={openFilePicker}
/>
) : (
- <MediaItemList
- items={filteredMediaItems}
- mode={mediaViewMode}
- onRemove={handleRemove}
- highlightedId={highlightedId}
- registerElement={registerElement}
- />
+ <SelectableSurface
+ ariaLabel="Assets"
+ orderedIds={orderedMediaIds}
+ revealId={highlightMediaId}
+ onRevealComplete={clearHighlight}
+ >
+ <MediaScopeRegistrar />
+ <MediaItemList
+ items={filteredMediaItems}
+ mode={mediaViewMode}
+ onRemove={handleRemove}
+ />
+ </SelectableSurface>
)}
</PanelView>
</>
);
}
+function MediaScopeRegistrar() {
+ useSelectionScope();
+ return null;
+}
+
function MediaAssetDraggable({
item,
preview,
- isHighlighted,
variant,
isRounded,
}: {
item: MediaAsset;
preview: React.ReactNode;
- isHighlighted: boolean;
variant: "card" | "compact";
isRounded?: boolean;
}) {
@@ -261,7 +281,7 @@ function MediaAssetDraggable({
mediaType: item.type,
name: item.name,
...(item.type !== "audio" && {
- targetElementTypes: ["video", "image"] as const,
+ targetElementTypes: [...MASKABLE_ELEMENT_TYPES],
}),
}}
shouldShowPlusOnDrag={false}
@@ -270,7 +290,6 @@ function MediaAssetDraggable({
}
variant={variant}
isRounded={isRounded}
- isHighlighted={isHighlighted}
/>
);
}
@@ -282,18 +301,31 @@ function MediaItemWithContextMenu({
}: {
item: MediaAsset;
children: React.ReactNode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onRemove: ({
+ event,
+ ids,
+ }: {
+ event: React.MouseEvent;
+ ids: string[];
+ }) => void;
}) {
+ const { isSelected, selectedIds } = useSelection();
+ const idsToDelete = isSelected(item.id) ? selectedIds : [item.id];
+ const deleteLabel =
+ idsToDelete.length > 1 ? `Delete ${idsToDelete.length} items` : "Delete";
+
return (
<ContextMenu>
- <ContextMenuTrigger>{children}</ContextMenuTrigger>
+ <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
- onClick={(event) => onRemove({ event, id: item.id })}
+ onClick={(event: React.MouseEvent<HTMLDivElement>) =>
+ onRemove({ event, ids: idsToDelete })
+ }
>
- Delete
+ {deleteLabel}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -304,27 +336,29 @@ function MediaItemList({
items,
mode,
onRemove,
- highlightedId,
- registerElement,
}: {
items: MediaAsset[];
mode: MediaViewMode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
- highlightedId: string | null;
- registerElement: (id: string, element: HTMLElement | null) => void;
+ onRemove: ({
+ event,
+ ids,
+ }: {
+ event: React.MouseEvent;
+ ids: string[];
+ }) => void;
}) {
const isGrid = mode === "grid";
return (
<div
- className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1")}
+ className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1.5")}
style={
isGrid ? { gridTemplateColumns: "repeat(auto-fill, 7rem)" } : undefined
}
>
{items.map((item) => (
- <div key={item.id} ref={(element) => registerElement(item.id, element)}>
- <MediaItemWithContextMenu item={item} onRemove={onRemove}>
+ <MediaItemWithContextMenu item={item} onRemove={onRemove} key={item.id}>
+ <SelectableItem className={cn(!isGrid && "w-full")} id={item.id}>
<MediaAssetDraggable
item={item}
preview={
@@ -335,16 +369,15 @@ function MediaItemList({
}
variant={isGrid ? "card" : "compact"}
isRounded={isGrid ? false : undefined}
- isHighlighted={highlightedId === item.id}
/>
- </MediaItemWithContextMenu>
- </div>
+ </SelectableItem>
+ </MediaItemWithContextMenu>
))}
</div>
);
}
-export function formatDuration({ duration }: { duration: number }) {
+function formatDuration({ duration }: { duration: number }) {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
@@ -406,7 +439,7 @@ function MediaPreview({
if (item.type === "image") {
return (
- <div className="relative flex size-full items-center justify-center">
+ <div className="relative flex size-full items-center justify-center bg-muted">
<Image
src={item.url ?? ""}
alt={item.name}
diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx
index c613bc2a..0ece9b19 100644
--- a/apps/web/src/components/editor/panels/assets/views/base-view.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx
@@ -5,6 +5,7 @@ interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
actions?: React.ReactNode;
children: React.ReactNode;
contentClassName?: string;
+ scrollClassName?: string;
hideHeader?: boolean;
ref?: React.Ref<HTMLDivElement>;
onScroll?: React.UIEventHandler<HTMLDivElement>;
@@ -17,6 +18,7 @@ export function PanelView({
children,
className,
contentClassName,
+ scrollClassName,
hideHeader = false,
ref,
onScroll,
@@ -30,15 +32,18 @@ export function PanelView({
{...rest}
>
{!hideHeader && (
- <div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b">
- <span className="text-muted-foreground text-sm">{title}</span>
+ <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b">
+ {title && (
+ <span className="text-muted-foreground text-sm">{title}</span>
+ )}
{actions}
</div>
)}
<div
className={cn(
- "scrollbar-thin size-full overflow-y-auto",
+ "scrollbar-hidden size-full overflow-y-auto",
hideHeader ? "pt-4" : "pt-2",
+ scrollClassName,
)}
ref={scrollRef}
onScroll={onScroll}
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 a73fd693..023e1578 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,5 @@
import { Button } from "@/components/ui/button";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import {
Select,
SelectContent,
@@ -10,7 +10,6 @@ import {
import { useState, useRef } from "react";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import {
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
TRANSCRIPTION_LANGUAGES,
@@ -18,12 +17,23 @@ import {
import type {
TranscriptionLanguage,
TranscriptionProgress,
-} from "@/types/transcription";
+} from "@/lib/transcription/types";
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";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+} from "@/components/section";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ AddTrackCommand,
+ BatchCommand,
+ InsertElementCommand,
+} from "@/lib/commands";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
@@ -69,26 +79,29 @@ export function Captions() {
setProcessingStep("Generating captions...");
const captionChunks = buildCaptionChunks({ segments: result.segments });
- const captionTrackId = editor.timeline.addTrack({
- type: "text",
- index: 0,
- });
+ const addTrackCommand = new AddTrackCommand("text", 0);
+ const insertCommands = captionChunks.map(
+ (caption, i) =>
+ new InsertElementCommand({
+ placement: {
+ mode: "explicit",
+ trackId: addTrackCommand.getTrackId(),
+ },
+ element: {
+ ...DEFAULTS.text.element,
+ name: `Caption ${i + 1}`,
+ content: caption.text,
+ duration: caption.duration,
+ startTime: caption.startTime,
+ fontSize: 65,
+ fontWeight: "bold",
+ },
+ }),
+ );
- for (let i = 0; i < captionChunks.length; i++) {
- const caption = captionChunks[i];
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: captionTrackId },
- element: {
- ...DEFAULT_TEXT_ELEMENT,
- name: `Caption ${i + 1}`,
- content: caption.text,
- duration: caption.duration,
- startTime: caption.startTime,
- fontSize: 65,
- fontWeight: "bold",
- },
- });
- }
+ editor.command.execute({
+ command: new BatchCommand([addTrackCommand, ...insertCommands]),
+ });
} catch (error) {
console.error("Transcription failed:", error);
setError(
@@ -114,43 +127,53 @@ export function Captions() {
};
return (
- <PanelView title="Captions" ref={containerRef}>
- <div className="flex flex-col gap-3">
- <Label>Language</Label>
- <Select
- value={selectedLanguage}
- onValueChange={(value) => handleLanguageChange({ value })}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select a language" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="auto">Auto detect</SelectItem>
- {TRANSCRIPTION_LANGUAGES.map((language) => (
- <SelectItem key={language.code} value={language.code}>
- {language.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="flex flex-col gap-4">
- {error && (
- <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
- <p className="text-destructive text-sm">{error}</p>
- </div>
- )}
+ <PanelView
+ title="Captions"
+ contentClassName="px-0 flex flex-col h-full"
+ ref={containerRef}
+ >
+ <Section showTopBorder={false} showBottomBorder={false} className="flex-1">
+ <SectionContent className="flex flex-col gap-4 h-full pt-1">
+ <SectionFields>
+ <SectionField label="Language">
+ <Select
+ value={selectedLanguage}
+ onValueChange={(value) => handleLanguageChange({ value })}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select a language" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="auto">Auto detect</SelectItem>
+ {TRANSCRIPTION_LANGUAGES.map((language) => (
+ <SelectItem key={language.code} value={language.code}>
+ {language.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </SectionFields>
- <Button
- className="w-full"
- onClick={handleGenerateTranscript}
- disabled={isProcessing}
- >
- {isProcessing && <Spinner className="mr-1" />}
- {isProcessing ? processingStep : "Generate transcript"}
- </Button>
- </div>
+ {error && (
+ <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
+ <p className="text-destructive text-sm">{error}</p>
+ </div>
+ )}
+ </SectionContent>
+ </Section>
+ <Section showBottomBorder={false} showTopBorder={false}>
+ <SectionContent>
+ <Button
+ className="w-full"
+ onClick={handleGenerateTranscript}
+ disabled={isProcessing}
+ >
+ {isProcessing && <Spinner className="mr-1" />}
+ {isProcessing ? processingStep : "Generate transcript"}
+ </Button>
+ </SectionContent>
+ </Section>
</PanelView>
);
}
diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx
index c9b8b2dd..74128df6 100644
--- a/apps/web/src/components/editor/panels/assets/views/effects.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx
@@ -1,19 +1,16 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
-import {
- effectPreviewService,
- onPreviewImageReady,
-} from "@/services/renderer/effect-preview";
+import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
import { useEditor } from "@/hooks/use-editor";
import { buildEffectElement } from "@/lib/timeline/element-utils";
-import type { EffectDefinition } from "@/types/effects";
+import type { EffectDefinition } from "@/lib/effects/types";
export function EffectsView() {
- const effects = getAllEffects();
+ const effects = effectsRegistry.getAll();
return (
<PanelView title="Effects">
@@ -50,7 +47,7 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) {
};
render();
- return onPreviewImageReady({ callback: render });
+ return effectPreviewService.onPreviewImageReady({ callback: render });
}, [effectType]);
return <canvas ref={canvasRef} className="size-full" />;
diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
deleted file mode 100644
index 457c08a9..00000000
--- a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
+++ /dev/null
@@ -1,332 +0,0 @@
-// @ts-nocheck
-
-"use client";
-
-import Image from "next/image";
-import { memo, useCallback, useMemo } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {
- BLUR_INTENSITY_PRESETS,
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_COLOR,
- FPS_PRESETS,
-} from "@/constants/project-constants";
-import { patternCraftGradients } from "@/data/colors/pattern-craft";
-import { colors } from "@/data/colors/solid";
-import { syntaxUIGradients } from "@/data/colors/syntax-ui";
-import { useEditor } from "@/hooks/use-editor";
-import { useEditorStore } from "@/stores/editor-store";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import { cn } from "@/utils/ui";
-import {
- PropertyGroup,
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "@/components/editor/panels/properties/section";
-
-export function SettingsView() {
- return (
- <PanelView title="Project settings" contentClassName="px-0">
- <div className="flex flex-col gap-8">
- <ProjectInfoView />
- <BackgroundView />
- </div>
- </PanelView>
- );
-}
-
-function ProjectInfoView() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { canvasPresets } = useEditorStore();
-
- const findPresetIndexByAspectRatio = ({
- presets,
- targetAspectRatio,
- }: {
- presets: Array<{ width: number; height: number }>;
- targetAspectRatio: string;
- }) => {
- for (let index = 0; index < presets.length; index++) {
- const preset = presets[index];
- const presetAspectRatio = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- if (presetAspectRatio === targetAspectRatio) {
- return index;
- }
- }
- return -1;
- };
-
- const currentCanvasSize = activeProject.settings.canvasSize;
- const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
- const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
- const presetIndex = findPresetIndexByAspectRatio({
- presets: canvasPresets,
- targetAspectRatio: currentAspectRatio,
- });
- const originalPresetValue = "original";
- const selectedPresetValue =
- presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
-
- const handleAspectRatioChange = ({ value }: { value: string }) => {
- if (value === originalPresetValue) {
- const canvasSize = originalCanvasSize ?? currentCanvasSize;
- editor.project.updateSettings({
- settings: { canvasSize },
- });
- return;
- }
- const index = parseInt(value, 10);
- const preset = canvasPresets[index];
- if (preset) {
- editor.project.updateSettings({ settings: { canvasSize: preset } });
- }
- };
-
- const handleFpsChange = (value: string) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- };
-
- return (
- <div className="flex flex-col gap-4">
- <PropertyItem direction="column">
- <PropertyItemLabel>Name</PropertyItemLabel>
- <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
- </PropertyItem>
-
- <PropertyItem direction="column">
- <PropertyItemLabel>Aspect ratio</PropertyItemLabel>
- <PropertyItemValue>
- <Select
- value={selectedPresetValue}
- onValueChange={(value) => handleAspectRatioChange({ value })}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select an aspect ratio" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={originalPresetValue}>Original</SelectItem>
- {canvasPresets.map((preset, index) => {
- const label = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- return (
- <SelectItem key={label} value={index.toString()}>
- {label}
- </SelectItem>
- );
- })}
- </SelectContent>
- </Select>
- </PropertyItemValue>
- </PropertyItem>
-
- <PropertyItem direction="column">
- <PropertyItemLabel>Frame rate</PropertyItemLabel>
- <PropertyItemValue>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={handleFpsChange}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select a frame rate" />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map((preset) => (
- <SelectItem key={preset.value} value={preset.value}>
- {preset.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </PropertyItemValue>
- </PropertyItem>
- </div>
- );
-}
-
-const BlurPreview = memo(
- ({
- blur,
- isSelected,
- onSelect,
- }: {
- blur: { label: string; value: number };
- isSelected: boolean;
- onSelect: () => void;
- }) => (
- <button
- className={cn(
- "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}
- type="button"
- aria-label={`Select ${blur.label} blur`}
- >
- <Image
- src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
- alt={`Blur preview ${blur.label}`}
- fill
- className="object-cover"
- style={{ filter: `blur(${blur.value}px)` }}
- loading="eager"
- />
- <div className="absolute right-1 bottom-1 left-1 text-center">
- <span className="rounded bg-black/50 px-1 text-xs text-white">
- {blur.label}
- </span>
- </div>
- </button>
- ),
-);
-
-BlurPreview.displayName = "BlurPreview";
-
-const BackgroundPreviews = memo(
- ({
- backgrounds,
- currentBackgroundColor,
- isColorBackground,
- handleColorSelect,
- useBackgroundColor = false,
- }: {
- backgrounds: string[];
- currentBackgroundColor: string;
- isColorBackground: boolean;
- handleColorSelect: ({ bg }: { bg: string }) => void;
- useBackgroundColor?: boolean;
- }) => {
- return useMemo(
- () =>
- backgrounds.map((bg, index) => (
- <button
- key={`${index}-${bg}`}
- className={cn(
- "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
- isColorBackground &&
- bg === currentBackgroundColor &&
- "border-primary border-2",
- )}
- style={
- useBackgroundColor
- ? { backgroundColor: bg }
- : {
- background: bg,
- backgroundSize: "cover",
- backgroundPosition: "center",
- backgroundRepeat: "no-repeat",
- }
- }
- onClick={() => handleColorSelect({ bg })}
- type="button"
- aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
- />
- )),
- [
- backgrounds,
- isColorBackground,
- currentBackgroundColor,
- handleColorSelect,
- useBackgroundColor,
- ],
- );
- },
-);
-
-BackgroundPreviews.displayName = "BackgroundPreviews";
-
-function BackgroundView() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
-
- const handleBlurSelect = useCallback(
- async ({ blurIntensity }: { blurIntensity: number }) => {
- await editor.project.updateSettings({
- settings: { background: { type: "blur", blurIntensity } },
- });
- },
- [editor.project],
- );
-
- const handleColorSelect = useCallback(
- async ({ color }: { color: string }) => {
- await editor.project.updateSettings({
- settings: { background: { type: "color", color } },
- });
- },
- [editor.project],
- );
-
- const currentBlurIntensity =
- activeProject.settings.background.type === "blur"
- ? activeProject.settings.background.blurIntensity
- : DEFAULT_BLUR_INTENSITY;
-
- const currentBackgroundColor =
- activeProject.settings.background.type === "color"
- ? activeProject.settings.background.color
- : DEFAULT_COLOR;
-
- const isBlurBackground = activeProject.settings.background.type === "blur";
- const isColorBackground = activeProject.settings.background.type === "color";
-
- const blurPreviews = useMemo(
- () =>
- blurLevels.map((blur) => (
- <BlurPreview
- key={blur.value}
- blur={blur}
- isSelected={isBlurBackground && currentBlurIntensity === blur.value}
- onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
- />
- )),
- [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 flex-col">
- <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
- <div className="flex flex-wrap gap-2">{blurPreviews}</div>
- </PropertyGroup>
-
- {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>
- </PropertyGroup>
- ))}
- </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
deleted file mode 100644
index d46e9d1f..00000000
--- a/apps/web/src/components/editor/panels/assets/views/settings.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-"use client";
-
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FPS_PRESETS } from "@/constants/project-constants";
-import { useEditor } from "@/hooks/use-editor";
-import { useEditorStore } from "@/stores/editor-store";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
-} from "@/components/editor/panels/properties/section";
-import { Label } from "@/components/ui/label";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-
-const ORIGINAL_PRESET_VALUE = "original";
-
-export function findPresetIndexByAspectRatio({
- presets,
- targetAspectRatio,
-}: {
- presets: Array<{ width: number; height: number }>;
- targetAspectRatio: string;
-}) {
- for (let index = 0; index < presets.length; index++) {
- const preset = presets[index];
- const presetAspectRatio = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- if (presetAspectRatio === targetAspectRatio) {
- return index;
- }
- }
- return -1;
-}
-
-export function SettingsView() {
- return (
- <PanelView contentClassName="px-0" hideHeader>
- <div className="flex flex-col">
- <Section showTopBorder={false}>
- <SectionContent>
- <ProjectInfoContent />
- </SectionContent>
- </Section>
- <Popover>
- <Section className="cursor-pointer">
- <PopoverTrigger asChild>
- <div>
- <SectionHeader
- trailing={<div className="size-4 rounded-sm bg-red-500" />}
- >
- <SectionTitle>Background</SectionTitle>
- </SectionHeader>
- </div>
- </PopoverTrigger>
- </Section>
- <PopoverContent>
- <div className="size-4 rounded-sm bg-red-500" />
- </PopoverContent>
- </Popover>
- </div>
- </PanelView>
- );
-}
-
-function ProjectInfoContent() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { canvasPresets } = useEditorStore();
-
- const currentCanvasSize = activeProject.settings.canvasSize;
- const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
- const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
- const presetIndex = findPresetIndexByAspectRatio({
- presets: canvasPresets,
- targetAspectRatio: currentAspectRatio,
- });
- const selectedPresetValue =
- presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE;
-
- const handleAspectRatioChange = ({ value }: { value: string }) => {
- if (value === ORIGINAL_PRESET_VALUE) {
- const canvasSize = originalCanvasSize ?? currentCanvasSize;
- editor.project.updateSettings({
- settings: { canvasSize },
- });
- return;
- }
- const index = parseInt(value, 10);
- const preset = canvasPresets[index];
- if (preset) {
- editor.project.updateSettings({ settings: { canvasSize: preset } });
- }
- };
-
- const handleFpsChange = ({ value }: { value: string }) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- };
-
- return (
- <div className="flex flex-col gap-4">
- <div className="flex flex-col gap-2">
- <Label>Name</Label>
- <span className="leading-none text-sm">
- {activeProject.metadata.name}
- </span>
- </div>
- <div className="flex flex-col gap-2">
- <Label>Aspect ratio</Label>
- <Select
- value={selectedPresetValue}
- onValueChange={(value) => handleAspectRatioChange({ value })}
- >
- <SelectTrigger className="w-fit">
- <SelectValue placeholder="Select an aspect ratio" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem>
- {canvasPresets.map((preset, index) => {
- const label = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- return (
- <SelectItem key={label} value={index.toString()}>
- {label}
- </SelectItem>
- );
- })}
- </SelectContent>
- </Select>
- </div>
- <div className="flex flex-col gap-2">
- <Label>Frame rate</Label>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={(value) => handleFpsChange({ value })}
- >
- <SelectTrigger className="w-fit">
- <SelectValue placeholder="Select a frame rate" />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map((preset) => (
- <SelectItem key={preset.value} value={preset.value}>
- {preset.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
new file mode 100644
index 00000000..b3ef1eb1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+} from "@/constants/project-constants";
+import { patternCraftGradients } from "@/data/colors/pattern-craft";
+import { colors } from "@/data/colors/solid";
+import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { useEditor } from "@/hooks/use-editor";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
+import type { TCanvasSize } from "@/lib/project/types";
+import { cn } from "@/utils/ui";
+
+const BlurPreview = memo(
+ ({
+ blur,
+ canvasSize,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ canvasSize: TCanvasSize;
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const { width, height } = canvasSize;
+
+ useEffect(() => {
+ const renderPreview = () => {
+ if (!canvasRef.current) return;
+
+ effectPreviewService.renderPreview({
+ effectType: "blur",
+ params: { intensity: blur.value },
+ targetCanvas: canvasRef.current,
+ uniformDimensions: { width, height },
+ });
+ };
+
+ renderPreview();
+ return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
+ }, [blur.value, width, height]);
+
+ return (
+ <button
+ className={cn(
+ "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}
+ type="button"
+ aria-label={`Select ${blur.label} blur`}
+ >
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 h-full w-full object-cover"
+ />
+ <div className="absolute right-1 bottom-1 left-1 text-center">
+ <span className="rounded bg-black/50 px-1 text-xs text-white">
+ {blur.label}
+ </span>
+ </div>
+ </button>
+ );
+ },
+);
+
+BlurPreview.displayName = "BlurPreview";
+
+const BackgroundPreviews = memo(
+ ({
+ backgrounds,
+ currentBackgroundColor,
+ isColorBackground,
+ onSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ onSelect: (bg: string) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg) => (
+ <button
+ key={bg}
+ className={cn(
+ "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
+ isColorBackground &&
+ bg === currentBackgroundColor &&
+ "border-primary border-2",
+ )}
+ style={
+ useBackgroundColor
+ ? { backgroundColor: bg }
+ : {
+ background: bg,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ }
+ }
+ onClick={() => onSelect(bg)}
+ type="button"
+ aria-label={`Select background ${bg}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ onSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+const COLOR_SECTIONS = [
+ { title: "Colors", backgrounds: colors, useBackgroundColor: true },
+ { title: "Pattern craft", backgrounds: patternCraftGradients },
+ { title: "Syntax UI", backgrounds: syntaxUIGradients },
+] as const;
+
+export function BackgroundContent() {
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+
+ const handleBlurSelect = useCallback(
+ async (blurIntensity: number) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async (color: string) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const currentBlurIntensity = isBlurBackground
+ ? (activeProject.settings.background as { blurIntensity: number })
+ .blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor = isColorBackground
+ ? (activeProject.settings.background as { color: string }).color
+ : DEFAULT_COLOR;
+ const canvasSize = activeProject.settings.canvasSize;
+
+ const blurPreviews = useMemo(
+ () =>
+ BLUR_INTENSITY_PRESETS.map((blur) => (
+ <BlurPreview
+ key={blur.value}
+ blur={blur}
+ canvasSize={canvasSize}
+ isSelected={isBlurBackground && currentBlurIntensity === blur.value}
+ onSelect={() => handleBlurSelect(blur.value)}
+ />
+ )),
+ [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>Blur</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">{blurPreviews}</div>
+ </SectionContent>
+ </Section>
+ {COLOR_SECTIONS.map((section) => (
+ <Section
+ key={section.title}
+ collapsible
+ defaultOpen={false}
+ sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
+ >
+ <SectionHeader>
+ <SectionTitle>{section.title}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">
+ <BackgroundPreviews
+ backgrounds={section.backgrounds as string[]}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ onSelect={handleColorSelect}
+ useBackgroundColor={
+ "useBackgroundColor" in section
+ ? section.useBackgroundColor
+ : false
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
new file mode 100644
index 00000000..e0229ea9
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -0,0 +1,369 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FPS_PRESETS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { BackgroundContent } from "./background";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { useEditorStore } from "@/stores/editor-store";
+import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Tick02Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { formatNumberForDisplay } from "@/utils/math";
+import { OcSquarePlusIcon } from "@opencut/ui/icons";
+import type { TCanvasSize } from "@/lib/project/types";
+
+type SettingsView = "project-info" | "background";
+
+const PRESET_LABELS: Record<string, string> = {
+ "1:1": "1:1",
+ "16:9": "16:9",
+ "9:16": "9:16",
+ "4:3": "4:3",
+};
+
+function areCanvasSizesEqual({
+ left,
+ right,
+}: {
+ left: TCanvasSize;
+ right: TCanvasSize;
+}) {
+ return left.width === right.width && left.height === right.height;
+}
+
+function formatCanvasDimension({ value }: { value: number }) {
+ return formatNumberForDisplay({ value, maxFractionDigits: 0 });
+}
+
+function parseCanvasDimension({ input }: { input: string }): number | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) return null;
+
+ const rounded = Math.round(parsed);
+ return rounded > 0 ? rounded : null;
+}
+
+function useCanvasDimensionDraft({
+ value,
+ onCommit,
+}: {
+ value: number;
+ onCommit: (value: number) => void;
+}) {
+ const pendingValueRef = useRef(value);
+ const syncedValueRef = useRef(value);
+
+ if (syncedValueRef.current !== value) {
+ syncedValueRef.current = value;
+ pendingValueRef.current = value;
+ }
+
+ return usePropertyDraft({
+ displayValue: formatCanvasDimension({ value }),
+ parse: (input) => parseCanvasDimension({ input }),
+ onPreview: (nextValue) => {
+ pendingValueRef.current = nextValue;
+ },
+ onCommit: () => {
+ if (pendingValueRef.current !== value) {
+ onCommit(pendingValueRef.current);
+ }
+ },
+ });
+}
+
+export function SettingsView() {
+ const [view, setView] = useState<SettingsView>("project-info");
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const { canvasPresets } = useEditorStore();
+ const currentCanvasSize = activeProject.settings.canvasSize;
+ const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
+ const lastCustomCanvasSize =
+ activeProject.settings.lastCustomCanvasSize ?? null;
+
+ const presetItems = canvasPresets.map((preset, index) => {
+ const ratio = dimensionToAspectRatio(preset);
+ return {
+ id: index.toString(),
+ label: PRESET_LABELS[ratio] ?? ratio,
+ ratio,
+ canvasSize: preset,
+ };
+ });
+
+ const selectedPresetId = canvasSizeMode === "preset"
+ ? (presetItems.find((preset) =>
+ areCanvasSizesEqual({
+ left: preset.canvasSize,
+ right: currentCanvasSize,
+ }),
+ )?.id ?? null)
+ : null;
+
+ const updateCustomCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateLastCustomCanvasSize =
+ lastCustomCanvasSize === null ||
+ !areCanvasSizesEqual({
+ left: canvasSize,
+ right: lastCustomCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
+
+ if (
+ !shouldUpdateCanvasSize &&
+ !shouldUpdateLastCustomCanvasSize &&
+ !shouldUpdateCanvasSizeMode
+ ) {
+ return;
+ }
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "custom" as const }
+ : {}),
+ lastCustomCanvasSize: canvasSize,
+ },
+ });
+ };
+
+ const selectPresetCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
+
+ if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "preset" as const }
+ : {}),
+ },
+ });
+ };
+
+ const selectCustomCanvasSize = () => {
+ updateCustomCanvasSize({
+ canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
+ });
+ };
+
+ const widthDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.width,
+ onCommit: (width) =>
+ updateCustomCanvasSize({
+ canvasSize: { width, height: currentCanvasSize.height },
+ }),
+ });
+
+ const heightDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.height,
+ onCommit: (height) =>
+ updateCustomCanvasSize({
+ canvasSize: { width: currentCanvasSize.width, height },
+ }),
+ });
+
+ const isCustomSelected = canvasSizeMode === "custom";
+
+ return (
+ <PanelView
+ contentClassName="px-0"
+ scrollClassName="pt-0"
+ actions={
+ <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
+ <TabsList>
+ <TabsTrigger value="project-info">Project info</TabsTrigger>
+ <TabsTrigger value="background">Background</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ }
+ >
+ {view === "project-info" && (
+ <div className="flex flex-col">
+ <Section showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle className="flex-1">Name</SectionTitle>
+ <span className="text-sm truncate">
+ {activeProject.metadata.name}
+ </span>
+ </SectionHeader>
+ </Section>
+ <Section showTopBorder={false}>
+ <SectionHeader className="justify-between">
+ <SectionTitle className="flex-1">Frame rate</SectionTitle>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={(value) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ }}
+ >
+ <SelectTrigger className="bg-transparent border-none p-1 h-auto">
+ <SelectValue placeholder="Select a frame rate" />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map((preset) => (
+ <SelectItem key={preset.value} value={preset.value}>
+ {preset.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionHeader>
+ </Section>
+ <Section
+ showTopBorder={false}
+ collapsible
+ sectionKey="settings:aspect-ratio"
+ >
+ <SectionHeader>
+ <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
+ </SectionHeader>
+ <SectionContent className="px-2 flex flex-col gap-1 pb-2">
+ {presetItems.map((preset) => (
+ <AspectRatioItem
+ key={preset.id}
+ label={preset.label}
+ previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
+ isSelected={selectedPresetId === preset.id}
+ onClick={() => {
+ selectPresetCanvasSize({
+ canvasSize: preset.canvasSize,
+ });
+ }}
+ />
+ ))}
+ <div className="pb-2">
+ <AspectRatioItem
+ key="custom"
+ label="Custom"
+ previewIcon={<OcSquarePlusIcon />}
+ isSelected={isCustomSelected}
+ onClick={selectCustomCanvasSize}
+ uiOptions={
+ <div className=" flex items-center gap-2 text-foreground">
+ <NumberField
+ value={widthDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas width"
+ onFocus={widthDraft.onFocus}
+ onChange={widthDraft.onChange}
+ onBlur={widthDraft.onBlur}
+ />
+ <NumberField
+ value={heightDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas height"
+ onFocus={heightDraft.onFocus}
+ onChange={heightDraft.onChange}
+ onBlur={heightDraft.onBlur}
+ />
+ </div>
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ </div>
+ )}
+ {view === "background" && <BackgroundContent />}
+ </PanelView>
+ );
+}
+
+function AspectRatioItem({
+ label,
+ previewIcon,
+ isSelected,
+ onClick,
+ uiOptions,
+}: {
+ label: string;
+ previewIcon: React.ReactNode;
+ isSelected: boolean;
+ onClick: () => void;
+ uiOptions?: React.ReactNode;
+}) {
+ return (
+ <Button
+ variant={isSelected ? "secondary" : "ghost"}
+ className={cn(
+ "px-2 py-0 flex flex-col h-fit w-full",
+ !isSelected && "border border-transparent opacity-75!",
+ )}
+ onClick={onClick}
+ >
+ <div className="w-full flex justify-between items-center h-8">
+ <div className="flex-1 flex items-center gap-2">
+ <div className="flex items-center justify-center size-5">
+ {previewIcon}
+ </div>
+ <span className="text-sm truncate">{label}</span>
+ </div>
+ <div>
+ {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
+ </div>
+ </div>
+ {uiOptions && isSelected && (
+ <div className="w-full pb-2">{uiOptions}</div>
+ )}
+ </Button>
+ );
+}
+
+function AspectRatioPreview({ ratio }: { ratio?: string }) {
+ if (!ratio) return null;
+
+ const [w, h] = ratio.split(":").map(Number);
+ const maxSize = 16;
+ const width = w >= h ? maxSize : (w / h) * maxSize;
+ const height = h >= w ? maxSize : (h / w) * maxSize;
+
+ return (
+ <div
+ style={{ width, height, borderWidth: 1.5 }}
+ className="rounded-xs border-current opacity-60"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
index b1c57c3d..4fb43ac2 100644
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -24,7 +24,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { useSoundSearch } from "@/hooks/use-sound-search";
import { useSoundsStore } from "@/stores/sounds-store";
-import type { SavedSound, SoundEffect } from "@/types/sounds";
+import type { SavedSound, SoundEffect } from "@/lib/sounds/types";
import { cn } from "@/utils/ui";
import {
FavouriteIcon,
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
index 671effec..af489630 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -2,81 +2,110 @@
import Image from "next/image";
import type { CSSProperties } from "react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { toast } from "sonner";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { useEditor } from "@/hooks/use-editor";
+import { resolveStickerIntrinsicSize } from "@/lib/stickers";
import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import {
- resolveStickerId,
- type StickerItem as StickerData,
+ buildGraphicElement,
+ buildStickerElement,
+} from "@/lib/timeline/element-utils";
+import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
+import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
+import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
+import type { TimelineDragData } from "@/lib/timeline/drag";
+import type {
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem as StickerData,
} from "@/lib/stickers";
import { useStickersStore } from "@/stores/stickers-store";
import { cn } from "@/utils/ui";
import {
HappyIcon,
- ClockIcon,
- MultiplicationSignIcon,
- Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { Spinner } from "@/components/ui/spinner";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { OcSlidersVerticalIcon } from "@opencut/ui/icons";
-import type { StickerCategory } from "@/types/stickers";
-import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
-import { parseStickerId } from "@/lib/stickers/sticker-id";
export function StickersView() {
- const { selectedCategory, setSelectedCategory } = useStickersStore();
+ const {
+ browseContent,
+ browseStickers,
+ searchQuery,
+ searchStickers,
+ selectedCategory,
+ setSearchQuery,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ useEffect(() => {
+ if (viewMode === "browse" && !browseContent) {
+ void browseStickers();
+ }
+ }, [browseContent, browseStickers, viewMode]);
return (
- <PanelView
- title="Stickers"
- actions={
- <div className="flex items-center">
- <Select
- value={selectedCategory}
- onValueChange={(value: StickerCategory) =>
- setSelectedCategory({ category: value })
- }
+ <div className="flex h-full flex-col py-2">
+ <div className="px-2">
+ <Input
+ size="sm"
+ variant="default"
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(e) => {
+ setSearchQuery({ query: e.target.value });
+ void searchStickers({ query: e.target.value });
+ }}
+ showClearIcon
+ onClear={() => {
+ setSearchQuery({ query: "" });
+ void searchStickers({ query: "" });
+ }}
+ className="w-full"
+ containerClassName="w-full"
+ />
+ </div>
+
+ <div className="mt-2 flex min-h-0 flex-1 flex-col">
+ <div className="border-b border-border px-2">
+ <div
+ role="tablist"
+ aria-label="Sticker categories"
+ className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
>
- <SelectTrigger variant="outline" size="sm" className="mr-1.5">
- <SelectValue placeholder="All" />
- </SelectTrigger>
- <SelectContent>
- {Object.entries(STICKER_CATEGORIES).map(([category, label]) => (
- <SelectItem key={category} value={category}>
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
+ const isActive = key === selectedCategory;
+ return (
+ <button
+ key={key}
+ type="button"
+ role="tab"
+ aria-selected={isActive}
+ onClick={() =>
+ setSelectedCategory({
+ category: key as StickerCategory,
+ })
+ }
+ className={cn(
+ "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
+ isActive && "text-primary border-primary",
+ )}
+ >
{label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
-
- <Button variant="ghost" size="icon">
- <HugeiconsIcon icon={Search01Icon} className="!size-3.5" />
- </Button>
-
- <Button variant="ghost" size="icon">
- <OcSlidersVerticalIcon className="!size-3.5" />
- </Button>
+ </button>
+ );
+ })}
+ </div>
</div>
- }
- >
- <StickersContentView />
- </PanelView>
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
+ <StickersContentView />
+ </div>
+ </div>
+ </div>
);
}
@@ -92,10 +121,10 @@ function StickerGrid({
"--sticker-max"?: string;
} = {
gridTemplateColumns: shouldCapSize
- ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
- : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
- "--sticker-min": "96px",
- ...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
+ ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
+ : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
+ "--sticker-min": "80px",
+ ...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
};
return (
@@ -107,6 +136,18 @@ function StickerGrid({
);
}
+function StickerRow({ items }: { items: StickerData[] }) {
+ return (
+ <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
+ {items.map((item) => (
+ <div key={item.id} className="w-20 shrink-0">
+ <StickerItem item={item} shouldCapSize containerClassName="w-full" />
+ </div>
+ ))}
+ </div>
+ );
+}
+
function EmptyView({ message }: { message: string }) {
return (
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
@@ -122,88 +163,170 @@ function EmptyView({ message }: { message: string }) {
);
}
+function RegionBanner({ region }: { region: string }) {
+ return (
+ <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className="shrink-0 text-sky-600"
+ aria-hidden="true"
+ >
+ <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
+ <circle cx="12" cy="10" r="3" />
+ </svg>
+ <span className="text-xs font-semibold text-sky-600">{region}</span>
+ </div>
+ );
+}
+
function StickersContentView() {
const {
+ browseContent,
+ clearRecentStickers,
+ isBrowsing,
+ isSearching,
searchQuery,
- viewMode,
searchResults,
- recentStickers,
- isSearching,
- clearRecentStickers,
+ selectedCategory,
+ setSelectedCategory,
+ viewMode,
} = useStickersStore();
- const itemsToDisplay = useMemo(() => {
- if (viewMode === "search" && searchResults) {
- return searchResults.items;
+ if (viewMode === "search") {
+ if (isSearching) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
}
- return [];
- }, [viewMode, searchResults]);
+ if (searchResults?.items.length) {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const isRegionSearch =
+ selectedCategory === "flags" &&
+ resolveQueryToRegions({ query: normalizedQuery }) !== null;
+ const regionLabel = getRegionLabel({ query: normalizedQuery });
- const recentStickerItems = useMemo(() => {
- const items: StickerData[] = [];
- for (const stickerId of recentStickers) {
- const recentStickerItem = toRecentStickerItem({ stickerId });
- if (recentStickerItem) {
- items.push(recentStickerItem);
- }
+ return (
+ <div className="flex flex-col gap-3 pb-4">
+ {isRegionSearch && <RegionBanner region={regionLabel} />}
+ <div className="flex items-center justify-between">
+ <span className="text-muted-foreground text-sm">
+ {searchResults.total} results
+ </span>
+ </div>
+ <StickerGrid items={searchResults.items} />
+ </div>
+ );
}
- return items;
- }, [recentStickers]);
+
+ // "all" tab search — sections are in browseContent, fall through to section rendering below
+ if (selectedCategory !== "all" && searchQuery) {
+ return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
+ }
+ }
+
+ if (isBrowsing && !browseContent) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (!browseContent?.sections.length) {
+ const categoryLabel = STICKER_CATEGORIES[selectedCategory];
+ return (
+ <EmptyView
+ message={
+ viewMode === "search"
+ ? `No stickers found for "${searchQuery}"`
+ : selectedCategory === "all"
+ ? "No stickers available yet."
+ : `No stickers available in ${categoryLabel.toLowerCase()} yet.`
+ }
+ />
+ );
+ }
return (
- <div className="flex h-full flex-col gap-4">
- {recentStickerItems.length > 0 && viewMode === "browse" && (
- <div className="flex h-full flex-col gap-2">
- <div className="flex items-center gap-2">
- <HugeiconsIcon
- icon={ClockIcon}
- className="text-muted-foreground size-4"
- />
- <span className="text-sm font-medium">Recent</span>
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <button
- type="button"
- onClick={clearRecentStickers}
- className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
- >
- <HugeiconsIcon
- icon={MultiplicationSignIcon}
- className="text-muted-foreground size-3"
- />
- </button>
- </TooltipTrigger>
- <TooltipContent>
- <p>Clear recent stickers</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
+ <div className="flex flex-col gap-4 pb-4">
+ {browseContent.sections.map((section) => (
+ <StickerSection
+ key={section.id}
+ section={section}
+ onClearRecent={clearRecentStickers}
+ onSeeAll={(category) => {
+ setSelectedCategory({ category });
+ }}
+ />
+ ))}
+ </div>
+ );
+}
+
+function StickerSection({
+ section,
+ onClearRecent,
+ onSeeAll,
+}: {
+ section: StickerBrowseSection;
+ onClearRecent: () => void;
+ onSeeAll: (category: StickerCategory) => void;
+}) {
+ const hasHeader =
+ Boolean(section.title) || section.id === "recent" || section.action;
+
+ return (
+ <div className="flex flex-col gap-2">
+ {hasHeader && (
+ <div className="flex items-center justify-between gap-2">
+ {section.title ? (
+ <p className="text-xs text-muted-foreground">{section.title}</p>
+ ) : (
+ <div />
+ )}
+
+ <div className="ml-auto flex items-center gap-2">
+ {section.id === "recent" && (
+ <Button
+ onClick={onClearRecent}
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-muted-foreground"
+ >
+ Clear
+ </Button>
+ )}
+
+ {section.action?.type === "see-all" && section.action.category && (
+ <Button
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-primary"
+ onClick={() => {
+ onSeeAll(section.action?.category as StickerCategory);
+ }}
+ >
+ See all
+ </Button>
+ )}
</div>
- <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
</div>
)}
- {viewMode === "search" && (
- <div className="h-full">
- {isSearching ? (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- ) : searchResults?.items.length ? (
- <div className="flex flex-col gap-3">
- <div className="flex items-center justify-between">
- <span className="text-muted-foreground text-sm">
- {searchResults.total} results
- </span>
- </div>
- <StickerGrid items={itemsToDisplay} shouldCapSize />
- </div>
- ) : searchQuery ? (
- <EmptyView message={`No stickers found for "${searchQuery}"`} />
- ) : null}
- </div>
+ {section.layout === "row" ? (
+ <StickerRow items={section.items} />
+ ) : (
+ <StickerGrid items={section.items} />
)}
</div>
);
@@ -212,65 +335,116 @@ function StickersContentView() {
interface StickerItemProps {
item: StickerData;
shouldCapSize?: boolean;
+ containerClassName?: string;
}
-function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
- const { addingSticker, addStickerToTimeline } = useStickersStore();
- const isAdding = addingSticker === item.id;
+function StickerItem({
+ item,
+ shouldCapSize = false,
+ containerClassName,
+}: StickerItemProps) {
+ const editor = useEditor();
+ const { addToRecentStickers } = useStickersStore();
+ const [isAdding, setIsAdding] = useState(false);
const [hasImageError, setHasImageError] = useState(false);
useEffect(() => {
if (!item.id) {
return;
}
+
setHasImageError(false);
}, [item.id]);
const displayName = item.name;
+ const shapePreset =
+ item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
const handleAdd = async () => {
+ setIsAdding(true);
try {
- await addStickerToTimeline({
- stickerId: item.id,
- name: item.name,
+ const currentTime = editor.playback.getCurrentTime();
+
+ let element:
+ | ReturnType<typeof buildGraphicElement>
+ | ReturnType<typeof buildStickerElement>;
+ if (shapePreset) {
+ element = buildGraphicElement({
+ definitionId: shapePreset.definitionId,
+ name: shapePreset.name,
+ startTime: currentTime,
+ params: shapePreset.params,
+ });
+ } else {
+ const { width: intrinsicWidth, height: intrinsicHeight } =
+ await resolveStickerIntrinsicSize({ stickerId: item.id });
+ element = buildStickerElement({
+ stickerId: item.id,
+ name: item.name,
+ startTime: currentTime,
+ intrinsicWidth,
+ intrinsicHeight,
+ });
+ }
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto" },
+ element,
});
+
+ addToRecentStickers({ stickerId: item.id });
} catch (error) {
console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline");
+ } finally {
+ setIsAdding(false);
}
};
- const preview = hasImageError ? (
- <div className="flex size-full items-center justify-center p-2">
- <span className="text-muted-foreground text-center text-xs break-all">
- {displayName}
- </span>
- </div>
- ) : (
- <div className="flex size-full items-center justify-center p-4">
- <Image
- src={item.previewUrl}
- alt={displayName}
- width={64}
- height={64}
- className="size-full object-contain"
- style={
- shouldCapSize
- ? {
- maxWidth: "var(--sticker-max, 160px)",
- maxHeight: "var(--sticker-max, 160px)",
- }
- : undefined
- }
- onError={() => {
- setHasImageError(true);
- }}
- loading="lazy"
- unoptimized
- />
+ const preview = (
+ <div className="flex size-full items-center justify-center p-3">
+ {hasImageError ? (
+ <span className="text-muted-foreground text-center text-xs break-all">
+ {displayName}
+ </span>
+ ) : (
+ <Image
+ src={item.previewUrl}
+ alt={displayName}
+ width={64}
+ height={64}
+ className="size-full object-contain"
+ style={
+ shouldCapSize
+ ? {
+ maxWidth: "var(--sticker-max, 160px)",
+ maxHeight: "var(--sticker-max, 160px)",
+ }
+ : undefined
+ }
+ onError={() => setHasImageError(true)}
+ loading="lazy"
+ unoptimized
+ />
+ )}
</div>
);
+ const dragData: TimelineDragData = shapePreset
+ ? {
+ id: item.id,
+ type: "graphic",
+ name: displayName,
+ definitionId: shapePreset.definitionId,
+ params: shapePreset.params ?? {},
+ }
+ : {
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ };
+
return (
<div
className={cn("relative", isAdding && "pointer-events-none opacity-50")}
@@ -278,18 +452,13 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
<DraggableItem
name={displayName}
preview={preview}
- dragData={{
- id: item.id,
- type: "sticker",
- name: displayName,
- stickerId: item.id,
- }}
+ dragData={dragData}
onAddToTimeline={handleAdd}
aspectRatio={1}
shouldShowLabel={false}
isRounded
variant="card"
- containerClassName="w-full"
+ containerClassName={containerClassName ?? "w-full"}
/>
{isAdding && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
@@ -299,36 +468,3 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
</div>
);
}
-
-function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
- const stickerIdParts = stickerId.split(":");
- if (stickerIdParts.length <= 1) {
- return stickerId;
- }
- return (
- stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
- stickerId
- );
-}
-
-function toRecentStickerItem({
- stickerId,
-}: {
- stickerId: string;
-}): StickerData | null {
- try {
- const { providerId } = parseStickerId({ stickerId });
- return {
- id: stickerId,
- provider: providerId,
- name: getStickerNameFromId({ stickerId }),
- previewUrl: resolveStickerId({
- stickerId,
- options: { width: 64, height: 64 },
- }),
- metadata: {},
- };
- } catch {
- return null;
- }
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx
index 3673acc3..075cc25b 100644
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -1,7 +1,7 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
import { buildTextElement } from "@/lib/timeline/element-utils";
export function TextView() {
@@ -12,7 +12,7 @@ export function TextView() {
if (!activeScene) return;
const element = buildTextElement({
- raw: DEFAULT_TEXT_ELEMENT,
+ raw: DEFAULTS.text.element,
startTime: currentTime,
});
@@ -33,9 +33,9 @@ export function TextView() {
}
dragData={{
id: "temp-text-id",
- type: DEFAULT_TEXT_ELEMENT.type,
- name: DEFAULT_TEXT_ELEMENT.name,
- content: DEFAULT_TEXT_ELEMENT.content,
+ type: DEFAULTS.text.element.type,
+ name: DEFAULTS.text.element.name,
+ content: DEFAULTS.text.element.content,
}}
aspectRatio={1}
onAddToTimeline={handleAddToTimeline}
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx
index d4bcdcae..13226378 100644
--- a/apps/web/src/components/editor/panels/preview/context-menu.tsx
+++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx
@@ -4,9 +4,12 @@ import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
+ ContextMenuSeparator,
} from "@/components/ui/context-menu";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
import { usePreviewStore } from "@/stores/preview-store";
+import { toast } from "sonner";
export function PreviewContextMenu({
onToggleFullscreen,
@@ -16,25 +19,55 @@ export function PreviewContextMenu({
containerRef: React.RefObject<HTMLElement | null>;
}) {
const editor = useEditor();
+ const viewport = usePreviewViewport();
const { overlays, setOverlayVisibility } = usePreviewStore();
+ const handleCopySnapshot = async () => {
+ const result = await editor.renderer.copySnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to copy snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
+ const handleSaveSnapshot = async () => {
+ const result = await editor.renderer.saveSnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to save snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
return (
<ContextMenuContent className="w-56" container={containerRef.current}>
+ <ContextMenuItem onClick={viewport.fitToScreen} inset>
+ Fit to screen
+ </ContextMenuItem>
+ <ContextMenuSeparator />
<ContextMenuItem onClick={onToggleFullscreen} inset>
Full screen
</ContextMenuItem>
- <ContextMenuItem onClick={() => editor.renderer.saveSnapshot()} inset>
+ <ContextMenuItem onClick={handleSaveSnapshot} inset>
Save snapshot
</ContextMenuItem>
+ <ContextMenuItem onClick={handleCopySnapshot} inset>
+ Copy snapshot
+ </ContextMenuItem>
+ <ContextMenuSeparator />
<ContextMenuCheckboxItem
checked={overlays.bookmarks}
onCheckedChange={(checked) =>
setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
}
>
- Show bookmarks
+ Show bookmark notes
</ContextMenuCheckboxItem>
- <ContextMenuItem inset>Show grid</ContextMenuItem>
</ContextMenuContent>
);
}
diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
new file mode 100644
index 00000000..ad7a621f
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { getGuideById } from "@/lib/guides";
+import { usePreviewStore } from "@/stores/preview-store";
+import { usePreviewViewport } from "./preview-viewport";
+
+export function GuideOverlay() {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const viewport = usePreviewViewport();
+ const guide = getGuideById(activeGuide);
+
+ if (!guide) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ left: viewport.sceneLeft,
+ top: viewport.sceneTop,
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ }}
+ >
+ {guide.renderOverlay({
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx
new file mode 100644
index 00000000..e6d3df51
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/guide-popover.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { AnimatePresence, motion } from "motion/react";
+import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides";
+import { usePreviewStore } from "@/stores/preview-store";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/utils/ui";
+
+export function GridPopover({ children }: { children: React.ReactNode }) {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const toggleGuide = usePreviewStore((state) => state.toggleGuide);
+ const activeGuideDef = getGuideById(activeGuide);
+ const options = activeGuideDef?.renderOptions?.();
+
+ return (
+ <Popover>
+ <PopoverTrigger>{children}</PopoverTrigger>
+ <PopoverContent sideOffset={8} className="w-60 px-0">
+ <div className="flex flex-col gap-2 px-4">
+ <Label>Guides</Label>
+ <div className="grid grid-cols-3 gap-1">
+ {GUIDE_REGISTRY.map((guide) => (
+ <GridItem
+ key={guide.id}
+ label={guide.label}
+ preview={guide.renderPreview()}
+ isSelected={activeGuide === guide.id}
+ onClick={() => toggleGuide(guide.id)}
+ />
+ ))}
+ </div>
+ </div>
+ <AnimatePresence>
+ {options && (
+ <motion.div
+ initial={{ height: 0, opacity: 0 }}
+ animate={{ height: "auto", opacity: 1 }}
+ exit={{ height: 0, opacity: 0 }}
+ transition={{
+ height: { type: "spring", duration: 0.2, bounce: 0 },
+ opacity: { duration: 0.2 },
+ }}
+ className="overflow-hidden"
+ >
+ <Separator className="my-3" />
+ <div className="px-4">{options}</div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function GridItem({
+ label,
+ preview,
+ isSelected,
+ onClick,
+}: {
+ label: string;
+ preview: React.ReactNode;
+ isSelected?: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ aria-pressed={isSelected}
+ className={cn(
+ "flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5",
+ isSelected && "bg-primary/5! text-primary",
+ )}
+ >
+ <div
+ className={cn(
+ "aspect-video bg-foreground/5 flex items-center justify-center rounded-sm",
+ isSelected && "bg-primary/5!",
+ )}
+ >
+ {preview}
+ </div>
+ <span
+ className={cn(
+ "text-xs",
+ isSelected ? "text-primary" : "text-muted-foreground",
+ )}
+ >
+ {label}
+ </span>
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
new file mode 100644
index 00000000..44b8a7f1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
@@ -0,0 +1,332 @@
+"use client";
+
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { IconSvgElement } from "@hugeicons/react";
+
+export const HANDLE_SIZE = 10;
+export const HANDLE_HIT_AREA_SIZE = 18;
+export const ICON_HANDLE_RADIUS = 10;
+export const EDGE_HANDLE_THIN_SIZE = 6;
+export const EDGE_HANDLE_THICK_SIZE = 14;
+export const LINE_HIT_AREA_SIZE = 48;
+
+export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
+ const normalized = ((angleDeg % 180) + 180) % 180;
+ if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
+ if (normalized < 67.5) return "cursor-nwse-resize";
+ if (normalized < 112.5) return "cursor-ns-resize";
+ return "cursor-nesw-resize";
+}
+
+export function HandleButton({
+ screen,
+ cursor,
+ hitAreaSize,
+ className,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ children,
+}: {
+ screen: { x: number; y: number };
+ cursor?: string;
+ hitAreaSize: number;
+ className?: string;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+ children: React.ReactNode;
+}) {
+ return (
+ <button
+ type="button"
+ className={cn(
+ "absolute flex items-center justify-center outline-none",
+ cursor,
+ className,
+ )}
+ style={{
+ left: screen.x - hitAreaSize / 2,
+ top: screen.y - hitAreaSize / 2,
+ width: hitAreaSize,
+ height: hitAreaSize,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ {children}
+ </button>
+ );
+}
+
+export function CornerHandle({
+ cursor,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ cursor?: string;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
+ />
+ </HandleButton>
+ );
+}
+
+export function EdgeHandle({
+ edge,
+ screen,
+ rotation,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ edge: "right" | "left" | "bottom";
+ screen: { x: number; y: number };
+ rotation: number;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ const isHorizontalEdge = edge === "right" || edge === "left";
+ const width = isHorizontalEdge
+ ? EDGE_HANDLE_THIN_SIZE
+ : EDGE_HANDLE_THICK_SIZE;
+ const height = isHorizontalEdge
+ ? EDGE_HANDLE_THICK_SIZE
+ : EDGE_HANDLE_THIN_SIZE;
+ const cursor = getResizeCursor({
+ angleDeg: isHorizontalEdge ? rotation : rotation + 90,
+ });
+
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width, height, transform: `rotate(${rotation}deg)` }}
+ />
+ </HandleButton>
+ );
+}
+
+export function IconHandle({
+ icon,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ icon: IconSvgElement;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ hitAreaSize={ICON_HANDLE_RADIUS * 2}
+ className="rounded-full bg-white text-black shadow-sm"
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
+ </HandleButton>
+ );
+}
+
+export function BoundingBoxOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ cursor,
+ dashed = false,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ cursor?: string;
+ dashed?: boolean;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className={cn("absolute overflow-visible", cursor)}
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <rect
+ x={0.5}
+ y={0.5}
+ width={Math.max(outlineWidth - 1, 0)}
+ height={Math.max(outlineHeight - 1, 0)}
+ fill="transparent"
+ stroke="white"
+ strokeDasharray={dashed ? "4 4" : undefined}
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function ShapeOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ pathData,
+ cursor,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ pathData: string;
+ cursor?: string;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className={cn("absolute overflow-visible", cursor)}
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <path
+ d={pathData}
+ fill="transparent"
+ stroke="white"
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function LineOverlay({
+ start,
+ end,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ const dx = end.x - start.x;
+ const dy = end.y - start.y;
+ const length = Math.sqrt(dx * dx + dy * dy);
+ const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const cx = (start.x + end.x) / 2;
+ const cy = (start.y + end.y) / 2;
+
+ const sharedStyle = {
+ left: cx - length / 2,
+ width: length,
+ transform: `rotate(${angleDeg}deg)`,
+ transformOrigin: "center center",
+ };
+
+ return (
+ <>
+ {onPointerDown && (
+ <div
+ className="absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - LINE_HIT_AREA_SIZE / 2,
+ height: LINE_HIT_AREA_SIZE,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ />
+ )}
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - 0.5,
+ height: 1,
+ backgroundColor: "white",
+ opacity: 0.75,
+ }}
+ />
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index df60db01..c625b9bf 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useMemo, useRef } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/hooks/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop";
@@ -12,50 +12,70 @@ import { buildScene } from "@/services/renderer/scene-builder";
import { getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
+import { GuideOverlay } from "./guide-overlay";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { usePreviewStore } from "@/stores/preview-store";
import { PreviewContextMenu } from "./context-menu";
import { PreviewToolbar } from "./toolbar";
+import {
+ PreviewViewportProvider,
+ usePreviewViewportState,
+} from "./preview-viewport";
function usePreviewSize() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const canvasSize = useEditor(
+ (e) => e.project.getActive()?.settings.canvasSize,
+ );
return {
- width: activeProject?.settings.canvasSize.width,
- height: activeProject?.settings.canvasSize.height,
+ width: canvasSize?.width,
+ height: canvasSize?.height,
};
}
+function normalizeWheelDelta({
+ delta,
+ deltaMode,
+ pageSize,
+}: {
+ delta: number;
+ deltaMode: number;
+ pageSize: number;
+}): number {
+ if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
+ return delta * 16;
+ }
+
+ if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
+ return delta * pageSize;
+ }
+
+ return delta;
+}
+
export function PreviewPanel() {
const containerRef = useRef<HTMLDivElement>(null);
- const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
+ const { toggleFullscreen } = useFullscreen({ containerRef });
return (
<div
ref={containerRef}
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
>
- <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
- <PreviewCanvas
- onToggleFullscreen={toggleFullscreen}
- containerRef={containerRef}
- />
- <RenderTreeController />
- </div>
- <PreviewToolbar
- isFullscreen={isFullscreen}
+ <PreviewCanvas
+ containerRef={containerRef}
onToggleFullscreen={toggleFullscreen}
/>
+ <RenderTreeController />
</div>
);
}
function RenderTreeController() {
const editor = useEditor();
- const tracks = editor.timeline.getTracks();
- const mediaAssets = editor.media.getAssets();
- const activeProject = editor.project.getActive();
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const activeProject = useEditor((e) => e.project.getActive());
const { width, height } = usePreviewSize();
@@ -79,23 +99,30 @@ function RenderTreeController() {
}
function PreviewCanvas({
- onToggleFullscreen,
containerRef,
+ onToggleFullscreen,
}: {
- onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>;
+ onToggleFullscreen: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
- const outerContainerRef = useRef<HTMLDivElement>(null);
- const canvasBoundsRef = useRef<HTMLDivElement>(null);
+ const viewportRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null);
const renderingRef = useRef(false);
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
- const containerSize = useContainerSize({ containerRef: outerContainerRef });
+ const viewportSize = useContainerSize({ containerRef: viewportRef });
const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const renderTree = useEditor((e) => e.renderer.getRenderTree());
const { overlays } = usePreviewStore();
+ const viewport = usePreviewViewportState({
+ canvasHeight: nativeHeight,
+ canvasWidth: nativeWidth,
+ viewportHeight: viewportSize.height,
+ viewportRef,
+ viewportWidth: viewportSize.width,
+ });
const renderer = useMemo(() => {
return new CanvasRenderer({
@@ -105,37 +132,6 @@ function PreviewCanvas({
});
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
- const displaySize = useMemo(() => {
- if (
- !nativeWidth ||
- !nativeHeight ||
- containerSize.width === 0 ||
- containerSize.height === 0
- ) {
- return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 };
- }
-
- const paddingBuffer = 4;
- const availableWidth = containerSize.width - paddingBuffer;
- const availableHeight = containerSize.height - paddingBuffer;
-
- const aspectRatio = nativeWidth / nativeHeight;
- const containerAspect = availableWidth / availableHeight;
-
- const displayWidth =
- containerAspect > aspectRatio
- ? availableHeight * aspectRatio
- : availableWidth;
- const displayHeight =
- containerAspect > aspectRatio
- ? availableHeight
- : availableWidth / aspectRatio;
-
- return { width: displayWidth, height: displayHeight };
- }, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]);
-
- const renderTree = editor.renderer.getRenderTree();
-
const render = useCallback(() => {
if (canvasRef.current && renderTree && !renderingRef.current) {
const time = editor.playback.getCurrentTime();
@@ -168,44 +164,130 @@ function PreviewCanvas({
useRafLoop(render);
+ useEffect(() => {
+ const container = viewportRef.current;
+ if (!container) return;
+
+ let pendingZoomDelta = 0;
+ let pendingPanDeltaX = 0;
+ let pendingPanDeltaY = 0;
+ let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
+ let panRafId: ReturnType<typeof requestAnimationFrame> | null = null;
+
+ const onWheel = (event: WheelEvent) => {
+ const normalizedDeltaX = normalizeWheelDelta({
+ delta: event.deltaX,
+ deltaMode: event.deltaMode,
+ pageSize: container.clientWidth,
+ });
+ const normalizedDeltaY = normalizeWheelDelta({
+ delta: event.deltaY,
+ deltaMode: event.deltaMode,
+ pageSize: container.clientHeight,
+ });
+ const isZoomGesture = event.ctrlKey || event.metaKey;
+ if (isZoomGesture) {
+ event.preventDefault();
+ pendingZoomDelta += normalizedDeltaY;
+
+ if (zoomRafId === null) {
+ zoomRafId = requestAnimationFrame(() => {
+ const cappedDelta =
+ Math.sign(pendingZoomDelta) *
+ Math.min(Math.abs(pendingZoomDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+
+ viewport.scaleZoom({ factor: zoomFactor });
+ pendingZoomDelta = 0;
+ zoomRafId = null;
+ });
+ }
+
+ return;
+ }
+
+ if (!viewport.canPan) {
+ return;
+ }
+
+ if (normalizedDeltaX === 0 && normalizedDeltaY === 0) {
+ return;
+ }
+
+ event.preventDefault();
+ pendingPanDeltaX += normalizedDeltaX;
+ pendingPanDeltaY += normalizedDeltaY;
+
+ if (panRafId === null) {
+ panRafId = requestAnimationFrame(() => {
+ viewport.panByScreenDelta({
+ deltaX: pendingPanDeltaX,
+ deltaY: pendingPanDeltaY,
+ });
+ pendingPanDeltaX = 0;
+ pendingPanDeltaY = 0;
+ panRafId = null;
+ });
+ }
+ };
+
+ container.addEventListener("wheel", onWheel, {
+ capture: true,
+ passive: false,
+ });
+
+ return () => {
+ container.removeEventListener("wheel", onWheel, {
+ capture: true,
+ });
+ if (zoomRafId !== null) {
+ cancelAnimationFrame(zoomRafId);
+ }
+ if (panRafId !== null) {
+ cancelAnimationFrame(panRafId);
+ }
+ };
+ }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]);
+
return (
- <div
- ref={outerContainerRef}
- className="relative flex size-full items-center justify-center"
- >
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- ref={canvasBoundsRef}
- className="relative"
- style={{ width: displaySize.width, height: displaySize.height }}
- >
- <canvas
- ref={canvasRef}
- width={nativeWidth}
- height={nativeHeight}
- className="block border"
- style={{
- width: displaySize.width,
- height: displaySize.height,
- background:
- activeProject.settings.background.type === "blur"
- ? "transparent"
- : activeProject?.settings.background.color,
- }}
+ <PreviewViewportProvider value={viewport}>
+ <div className="flex size-full min-h-0 min-w-0 flex-col">
+ <div className="flex min-h-0 min-w-0 flex-1 p-2 pb-0">
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ ref={viewportRef}
+ className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
+ >
+ <canvas
+ ref={canvasRef}
+ width={nativeWidth}
+ height={nativeHeight}
+ className="absolute block border"
+ style={{
+ left: viewport.sceneLeft,
+ top: viewport.sceneTop,
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ background:
+ activeProject.settings.background.type === "blur"
+ ? "transparent"
+ : activeProject?.settings.background.color,
+ }}
+ />
+ <GuideOverlay />
+ <PreviewInteractionOverlay />
+ {overlays.bookmarks && <BookmarkNoteOverlay />}
+ </div>
+ </ContextMenuTrigger>
+ <PreviewContextMenu
+ onToggleFullscreen={onToggleFullscreen}
+ containerRef={containerRef}
/>
- <PreviewInteractionOverlay
- canvasRef={canvasRef}
- containerRef={canvasBoundsRef}
- />
- {overlays.bookmarks && <BookmarkNoteOverlay />}
- </div>
- </ContextMenuTrigger>
- <PreviewContextMenu
- onToggleFullscreen={onToggleFullscreen}
- containerRef={containerRef}
- />
- </ContextMenu>
- </div>
+ </ContextMenu>
+ </div>
+ <PreviewToolbar onToggleFullscreen={onToggleFullscreen} />
+ </div>
+ </PreviewViewportProvider>
);
}
diff --git a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
deleted file mode 100644
index 44efb4c8..00000000
--- a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-"use client";
-
-import { usePreviewStore } from "@/stores/preview-store";
-import Image from "next/image";
-
-function TikTokGuide() {
- return (
- <div className="pointer-events-none absolute inset-0">
- <Image
- src="/platform-guides/tiktok-blueprint.png"
- alt="TikTok layout guide"
- className="absolute inset-0 size-full object-contain"
- draggable={false}
- fill
- />
- </div>
- );
-}
-
-export function LayoutGuideOverlay() {
- const { layoutGuide } = usePreviewStore();
-
- if (layoutGuide.platform === null) return null;
- if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
-
- return null;
-}
diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
new file mode 100644
index 00000000..0c11f27a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useMaskHandles } from "@/hooks/use-mask-handles";
+import { masksRegistry } from "@/lib/masks";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import type { ParamValues } from "@/lib/params";
+import type { RectangleMaskParams } from "@/lib/masks/types";
+import {
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ LineOverlay,
+ BoundingBoxOutline,
+ ShapeOutline,
+} from "./handle-primitives";
+import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
+
+function hasRectangleOutlineParams(
+ params: ParamValues,
+): params is RectangleMaskParams {
+ return (
+ typeof params.centerX === "number" &&
+ typeof params.centerY === "number" &&
+ typeof params.width === "number" &&
+ typeof params.height === "number"
+ );
+}
+
+export function MaskHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}) {
+ const viewport = usePreviewViewport();
+ const {
+ selectedWithMask,
+ handlePositions,
+ linePoints,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ } = useMaskHandles({ onSnapLinesChange });
+
+ if (!selectedWithMask) return null;
+
+ const toOverlay = ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) =>
+ viewport.canvasToOverlay({
+ canvasX,
+ canvasY,
+ });
+
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+ const { bounds } = selectedWithMask;
+ const maskRotation = selectedWithMask.mask.params.rotation;
+
+ const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
+
+ const rectangleOutlineProps = hasRectangleOutlineParams(
+ selectedWithMask.mask.params,
+ )
+ ? {
+ center: toOverlay({
+ canvasX:
+ bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
+ canvasY:
+ bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
+ }),
+ outlineWidth:
+ selectedWithMask.mask.params.width * bounds.width * scaleX,
+ outlineHeight:
+ selectedWithMask.mask.params.height * bounds.height * scaleY,
+ rotation: maskRotation,
+ }
+ : null;
+
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
+ return (
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
+ {def.overlayShape === "line" && linePoints && (
+ <LineOverlay
+ start={toOverlay({
+ canvasX: linePoints.start.x,
+ canvasY: linePoints.start.y,
+ })}
+ end={toOverlay({
+ canvasX: linePoints.end.x,
+ canvasY: linePoints.end.y,
+ })}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: "position" })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ )}
+ {def.overlayShape === "box" && rectangleOutlineProps && (
+ def.buildOverlayPath ? (
+ <>
+ <BoundingBoxOutline {...rectangleOutlineProps} dashed />
+ <ShapeOutline
+ {...rectangleOutlineProps}
+ pathData={def.buildOverlayPath({
+ width: rectangleOutlineProps.outlineWidth,
+ height: rectangleOutlineProps.outlineHeight,
+ })}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: "position" })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ </>
+ ) : (
+ <BoundingBoxOutline
+ {...rectangleOutlineProps}
+ cursor="cursor-move"
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: "position" })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ )
+ )}
+ {handlePositions.map((handle) => {
+ const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
+
+ if (handle.id === "rotation") {
+ return (
+ <IconHandle
+ key={handle.id}
+ icon={Rotate01Icon}
+ screen={screen}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.id === "feather") {
+ return (
+ <IconHandle
+ key={handle.id}
+ icon={FeatherIcon}
+ screen={screen}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.id === "right" || handle.id === "left") {
+ return (
+ <EdgeHandle
+ key={handle.id}
+ edge="right"
+ screen={screen}
+ rotation={maskRotation}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.id === "bottom" || handle.id === "top") {
+ return (
+ <EdgeHandle
+ key={handle.id}
+ edge="bottom"
+ screen={screen}
+ rotation={maskRotation}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (
+ handle.id === "top-left" ||
+ handle.id === "top-right" ||
+ handle.id === "bottom-left" ||
+ handle.id === "bottom-right" ||
+ handle.id === "scale"
+ ) {
+ return (
+ <CornerHandle
+ key={handle.id}
+ screen={screen}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ return (
+ <CornerHandle
+ key={handle.id}
+ cursor={handle.cursor}
+ screen={screen}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
index 4c33a1a1..65c11726 100644
--- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
@@ -1,54 +1,117 @@
+import { useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
+import type { SnapLine } from "@/lib/preview/preview-snap";
import { TransformHandles } from "./transform-handles";
+import { MaskHandles } from "./mask-handles";
import { SnapGuides } from "./snap-guides";
import { TextEditOverlay } from "./text-edit-overlay";
+import { usePropertiesStore } from "../properties/stores/properties-store";
+import { useEditor } from "@/hooks/use-editor";
+
+export function PreviewInteractionOverlay() {
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
+
+ const selectedRef =
+ selectedElements.length === 1 ? selectedElements[0] : null;
+ const activeTrack = selectedRef
+ ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
+ : null;
+ const activeElement =
+ activeTrack?.elements.find(
+ (element) => element.id === selectedRef?.elementId,
+ ) ?? null;
+ const isMaskMode = activeElement
+ ? activeTabPerType[activeElement.type] === "masks"
+ : false;
-export function PreviewInteractionOverlay({
- canvasRef,
- containerRef,
-}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
-}) {
const {
onPointerDown,
onPointerMove,
onPointerUp,
onDoubleClick,
- snapLines,
editingText,
commitTextEdit,
- cancelTextEdit,
- } = usePreviewInteraction({ canvasRef });
+ } = usePreviewInteraction({
+ onSnapLinesChange: setSnapLines,
+ isMaskMode,
+ });
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerDown({ event })) {
+ return;
+ }
+
+ onPointerDown(event);
+ };
+
+ const handlePointerMove = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerMove({ event })) {
+ return;
+ }
+
+ onPointerMove(event);
+ };
+
+ const handlePointerUp = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerUp({ event })) {
+ return;
+ }
+
+ onPointerUp(event);
+ };
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ const x = e.nativeEvent.offsetX;
+ const y = e.nativeEvent.offsetY;
+ const outsideCanvas =
+ x < viewport.sceneLeft ||
+ x > viewport.sceneLeft + viewport.sceneWidth ||
+ y < viewport.sceneTop ||
+ y > viewport.sceneTop + viewport.sceneHeight;
+ if (outsideCanvas) {
+ e.stopPropagation();
+ }
+ };
return (
<div className="absolute inset-0">
- {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
<div
className="absolute inset-0 pointer-events-auto"
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
+ role="application"
+ aria-label="Preview canvas"
+ style={{
+ cursor: viewport.isPanning
+ ? "grabbing"
+ : viewport.canPan
+ ? "default"
+ : undefined,
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerCancel={handlePointerUp}
onDoubleClick={onDoubleClick}
+ onDragStart={(e) => e.preventDefault()}
+ onContextMenu={handleContextMenu}
/>
{editingText ? (
<TextEditOverlay
- canvasRef={canvasRef}
- containerRef={containerRef}
trackId={editingText.trackId}
elementId={editingText.elementId}
element={editingText.element}
onCommit={commitTextEdit}
- onCancel={cancelTextEdit}
/>
+ ) : isMaskMode ? (
+ <MaskHandles onSnapLinesChange={setSnapLines} />
) : (
- <TransformHandles canvasRef={canvasRef} containerRef={containerRef} />
+ <TransformHandles onSnapLinesChange={setSnapLines} />
)}
- <SnapGuides
- lines={snapLines}
- canvasRef={canvasRef}
- containerRef={containerRef}
- />
+ <SnapGuides lines={snapLines} />
</div>
);
}
diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
new file mode 100644
index 00000000..e38183af
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
@@ -0,0 +1,576 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ canvasToOverlay,
+ getDisplayScale,
+ positionToOverlay,
+ screenPixelsToLogicalThreshold,
+ screenToCanvas,
+} from "@/lib/preview/preview-coords";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import { PREVIEW_ZOOM } from "@/constants/editor-constants";
+
+const MIDDLE_MOUSE_BUTTON = 1;
+const IS_AT_FIT_EPSILON = 0.001;
+const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
+
+interface PreviewViewportContextValue {
+ canPan: boolean;
+ isAtFit: boolean;
+ isAtActualSize: boolean;
+ isPanning: boolean;
+ sceneHeight: number;
+ sceneLeft: number;
+ sceneTop: number;
+ sceneWidth: number;
+ zoomPercent: number;
+ canvasToOverlay: ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) => { x: number; y: number };
+ fitToScreen: () => void;
+ getDisplayScale: () => { x: number; y: number };
+ handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
+ positionToOverlay: ({
+ positionX,
+ positionY,
+ }: {
+ positionX: number;
+ positionY: number;
+ }) => { x: number; y: number };
+ panByScreenDelta: ({
+ deltaX,
+ deltaY,
+ }: {
+ deltaX: number;
+ deltaY: number;
+ }) => void;
+ resetPan: () => void;
+ scaleZoom: ({ factor }: { factor: number }) => void;
+ screenPixelsToLogicalThreshold: ({
+ screenPixels,
+ }: {
+ screenPixels: number;
+ }) => { x: number; y: number };
+ screenToCanvas: ({
+ clientX,
+ clientY,
+ }: {
+ clientX: number;
+ clientY: number;
+ }) => { x: number; y: number } | null;
+ setActualSize: () => void;
+ setViewportPercent: ({ percent }: { percent: number }) => void;
+ zoomIn: () => void;
+ zoomOut: () => void;
+}
+
+const PreviewViewportContext =
+ createContext<PreviewViewportContextValue | null>(null);
+
+interface PreviewViewportProviderProps {
+ children: React.ReactNode;
+ value: PreviewViewportContextValue;
+}
+
+interface PreviewViewportStateOptions {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportRef: React.RefObject<HTMLDivElement | null>;
+ viewportWidth: number;
+}
+
+interface PanSession {
+ centerX: number;
+ centerY: number;
+ clientX: number;
+ clientY: number;
+ pointerId: number;
+}
+
+function getClampedCenterAxis({
+ axisSize,
+ center,
+ scale,
+ viewportSize,
+}: {
+ axisSize: number;
+ center: number;
+ scale: number;
+ viewportSize: number;
+}): number {
+ if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
+ return axisSize / 2;
+ }
+
+ const visibleHalfSpan = viewportSize / (2 * scale);
+ if (visibleHalfSpan >= axisSize / 2) {
+ return axisSize / 2;
+ }
+
+ return clamp({
+ value: center,
+ min: visibleHalfSpan,
+ max: axisSize - visibleHalfSpan,
+ });
+}
+
+function clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX,
+ centerY,
+ scale,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ centerX: number;
+ centerY: number;
+ scale: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): { x: number; y: number } {
+ return {
+ x: getClampedCenterAxis({
+ center: centerX,
+ axisSize: canvasWidth,
+ scale,
+ viewportSize: viewportWidth,
+ }),
+ y: getClampedCenterAxis({
+ center: centerY,
+ axisSize: canvasHeight,
+ scale,
+ viewportSize: viewportHeight,
+ }),
+ };
+}
+
+function getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): number {
+ if (
+ canvasHeight <= 0 ||
+ canvasWidth <= 0 ||
+ viewportHeight <= 0 ||
+ viewportWidth <= 0
+ ) {
+ return 1;
+ }
+
+ return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
+}
+
+function getClampedZoom({ zoom }: { zoom: number }): number {
+ return clamp({
+ value: zoom,
+ min: PREVIEW_ZOOM.min,
+ max: PREVIEW_ZOOM.max,
+ });
+}
+
+export function PreviewViewportProvider({
+ children,
+ value,
+}: PreviewViewportProviderProps) {
+ return (
+ <PreviewViewportContext.Provider value={value}>
+ {children}
+ </PreviewViewportContext.Provider>
+ );
+}
+
+export function usePreviewViewportState({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportRef,
+ viewportWidth,
+}: PreviewViewportStateOptions): PreviewViewportContextValue {
+ const [zoom, setZoomState] = useState(1);
+ const [center, setCenter] = useState(() => ({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ }));
+ const [isPanning, setIsPanning] = useState(false);
+ const panSessionRef = useRef<PanSession | null>(null);
+ const centerRef = useRef(center);
+ centerRef.current = center;
+
+ const fitScale = useMemo(
+ () =>
+ getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [canvasHeight, canvasWidth, viewportHeight, viewportWidth],
+ );
+ const viewportScale = fitScale * zoom;
+ const geometry = useMemo(
+ () => ({
+ canvasHeight,
+ canvasWidth,
+ centerX: center.x,
+ centerY: center.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [
+ canvasHeight,
+ canvasWidth,
+ center.x,
+ center.y,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ ],
+ );
+
+ const scaleZoom = useCallback(({ factor }: { factor: number }) => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * factor,
+ }),
+ );
+ }, []);
+
+ const panByScreenDelta = useCallback(
+ ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
+ if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
+ return;
+ }
+
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x + deltaX / viewportScale,
+ centerY: previousCenter.y + deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ },
+ [
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ zoom,
+ ],
+ );
+
+ const resetPan = useCallback(() => {
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const fitToScreen = useCallback(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const zoomIn = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const zoomOut = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom / PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const setActualSize = useCallback(() => {
+ const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
+ setZoomState(
+ getClampedZoom({
+ zoom: actualSizeZoom,
+ }),
+ );
+ }, [fitScale]);
+
+ const setViewportPercent = useCallback(
+ ({ percent }: { percent: number }) => {
+ const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
+ setZoomState(getClampedZoom({ zoom: targetZoom }));
+ },
+ [fitScale],
+ );
+
+ const getViewportRect = useCallback(
+ () => viewportRef.current?.getBoundingClientRect() ?? null,
+ [viewportRef],
+ );
+
+ const screenToCanvasWithViewport = useCallback(
+ ({ clientX, clientY }: { clientX: number; clientY: number }) => {
+ const viewportRect = getViewportRect();
+ if (!viewportRect) return null;
+
+ return screenToCanvas({
+ clientX,
+ clientY,
+ geometry,
+ viewportRect,
+ });
+ },
+ [getViewportRect, geometry],
+ );
+
+ const canvasToOverlayWithViewport = useCallback(
+ ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
+ canvasToOverlay({
+ canvasX,
+ canvasY,
+ geometry,
+ }),
+ [geometry],
+ );
+
+ const positionToOverlayWithViewport = useCallback(
+ ({ positionX, positionY }: { positionX: number; positionY: number }) =>
+ positionToOverlay({
+ geometry,
+ positionX,
+ positionY,
+ }),
+ [geometry],
+ );
+
+ const getDisplayScaleWithViewport = useCallback(
+ () => getDisplayScale({ geometry }),
+ [geometry],
+ );
+
+ const getLogicalThresholdForScreenPixels = useCallback(
+ ({ screenPixels }: { screenPixels: number }) =>
+ screenPixelsToLogicalThreshold({
+ geometry,
+ screenPixels,
+ }),
+ [geometry],
+ );
+
+ const handlePanPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ panSessionRef.current = {
+ centerX: centerRef.current.x,
+ centerY: centerRef.current.y,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ pointerId: event.pointerId,
+ };
+ setIsPanning(true);
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ return true;
+ },
+ [zoom],
+ );
+
+ const handlePanPointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const deltaX = event.clientX - panSession.clientX;
+ const deltaY = event.clientY - panSession.clientY;
+ const nextCenter = clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: panSession.centerX - deltaX / viewportScale,
+ centerY: panSession.centerY - deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ });
+
+ setCenter(nextCenter);
+ return true;
+ },
+ [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
+ );
+
+ const handlePanPointerUp = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ if (
+ (event.currentTarget as HTMLElement).hasPointerCapture(
+ panSession.pointerId,
+ )
+ ) {
+ (event.currentTarget as HTMLElement).releasePointerCapture(
+ panSession.pointerId,
+ );
+ }
+
+ panSessionRef.current = null;
+ setIsPanning(false);
+ return true;
+ },
+ [],
+ );
+
+ useEffect(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ panSessionRef.current = null;
+ setIsPanning(false);
+ }, [canvasHeight, canvasWidth]);
+
+ useEffect(() => {
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x,
+ centerY: previousCenter.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
+
+ const sceneWidth = canvasWidth * viewportScale;
+ const sceneHeight = canvasHeight * viewportScale;
+ const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
+ const sceneTop = viewportHeight / 2 - center.y * viewportScale;
+ const canPan = zoom > 1;
+ const zoomPercent = Math.round(viewportScale * 100);
+
+ return useMemo(
+ () => ({
+ canPan,
+ isAtActualSize: isNearlyEqual({
+ leftValue: viewportScale,
+ rightValue: 1,
+ epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
+ }),
+ isAtFit: isNearlyEqual({
+ leftValue: zoom,
+ rightValue: 1,
+ epsilon: IS_AT_FIT_EPSILON,
+ }),
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlay: canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScale: getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlay: positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
+ screenToCanvas: screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ }),
+ [
+ canPan,
+ viewportScale,
+ zoom,
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ getLogicalThresholdForScreenPixels,
+ screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ ],
+ );
+}
+
+export function usePreviewViewport() {
+ const context = useContext(PreviewViewportContext);
+ if (!context) {
+ throw new Error(
+ "usePreviewViewport must be used within PreviewViewportProvider",
+ );
+ }
+
+ return context;
+}
diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
index 4aa714a1..744304ea 100644
--- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx
+++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
@@ -1,43 +1,25 @@
"use client";
-import { useEditor } from "@/hooks/use-editor";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import type { SnapLine } from "@/lib/preview/preview-snap";
-import { positionToOverlay } from "@/lib/preview/preview-coords";
-export function SnapGuides({
- lines,
- canvasRef,
- containerRef,
-}: {
- lines: SnapLine[];
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
-}) {
- const editor = useEditor();
- const canvasSize = editor.project.getActive().settings.canvasSize;
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
+export function SnapGuides({ lines }: { lines: SnapLine[] }) {
+ const viewport = usePreviewViewport();
- if (!canvasRect || !containerRect || lines.length === 0) {
+ if (lines.length === 0) {
return null;
}
const toOverlayX = (logicalX: number) =>
- positionToOverlay({
+ viewport.positionToOverlay({
positionX: logicalX,
positionY: 0,
- canvasRect,
- containerRect,
- canvasSize,
}).x;
const toOverlayY = (logicalY: number) =>
- positionToOverlay({
+ viewport.positionToOverlay({
positionX: 0,
positionY: logicalY,
- canvasRect,
- containerRect,
- canvasSize,
}).y;
return (
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
index 26649b26..d3c61a24 100644
--- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
@@ -1,38 +1,89 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
-import type { TextElement } from "@/types/timeline";
+import type { TextElement } from "@/lib/timeline";
import {
- positionToOverlay,
- getDisplayScale,
-} from "@/lib/preview/preview-coords";
-import {
- DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ getMetricAscent,
+ getMetricDescent,
+ setCanvasLetterSpacing,
+} from "@/lib/text/layout";
+
+let cachedCanvas: HTMLCanvasElement | null = null;
+
+function getMeasurementContext(): CanvasRenderingContext2D | null {
+ if (!cachedCanvas) cachedCanvas = document.createElement("canvas");
+ return cachedCanvas.getContext("2d");
+}
+
+/**
+ * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than
+ * actualBoundingBox to model where the browser places the baseline within the
+ * line box, then measures actual glyph bounds to find the visual center.
+ */
+function measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx,
+ lineHeightPx,
+ displayFontSize,
+}: {
+ lines: string[];
+ fontString: string;
+ letterSpacingPx: number;
+ lineHeightPx: number;
+ displayFontSize: number;
+}): number {
+ const ctx = getMeasurementContext();
+ if (!ctx) return 0;
+
+ ctx.font = fontString;
+ ctx.textBaseline = "alphabetic";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx });
+
+ const probe = ctx.measureText("M");
+ const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8;
+ const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2;
+ const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2;
-const TEXT_BACKGROUND_PADDING = "4px 8px";
-const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
+ let visualTop = Number.POSITIVE_INFINITY;
+ let visualBottom = Number.NEGATIVE_INFINITY;
+
+ for (let i = 0; i < lines.length; i++) {
+ const metrics = ctx.measureText(lines[i] || " ");
+ const baseline = i * lineHeightPx + halfLeading + fontAscent;
+ visualTop = Math.min(
+ visualTop,
+ baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ visualBottom = Math.max(
+ visualBottom,
+ baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ }
+
+ const cssBlockHeight = lines.length * lineHeightPx;
+ return (visualTop + visualBottom) / 2 - cssBlockHeight / 2;
+}
export function TextEditOverlay({
- canvasRef,
- containerRef,
trackId,
elementId,
element,
onCommit,
- onCancel,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
trackId: string;
elementId: string;
element: TextElement;
onCommit: () => void;
- onCancel: () => void;
}) {
const editor = useEditor();
+ const viewport = usePreviewViewport();
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -60,59 +111,67 @@ export function TextEditOverlay({
const { key } = event;
if (key === "Escape") {
event.preventDefault();
- onCancel();
+ onCommit();
return;
}
},
- [onCancel],
+ [onCommit],
);
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
const canvasSize = editor.project.getActive().settings.canvasSize;
- if (!canvasRect || !containerRect || !canvasSize) return null;
+ if (!canvasSize) return null;
- const { x: posX, y: posY } = positionToOverlay({
+ const { x: posX, y: posY } = viewport.positionToOverlay({
positionX: element.transform.position.x,
positionY: element.transform.position.y,
- canvasRect,
- containerRect,
- canvasSize,
});
- const { x: displayScaleX } = getDisplayScale({
- canvasRect,
- canvasSize,
- });
+ const { x: displayScaleX } = viewport.getDisplayScale();
const displayFontSize =
element.fontSize *
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
displayScaleX;
- const verticalAlignmentOffset =
- displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM;
-
- const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const letterSpacing = element.letterSpacing ?? 0;
+ const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX;
+ const lineHeightPx = displayFontSize * lineHeight;
+ const lines = (element.content || "").split("\n");
+ const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`;
+
+ const cssVisualCenterOffset = measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx: displayLetterSpacing,
+ lineHeightPx,
+ displayFontSize,
+ });
+
+ const bg = element.background;
const shouldShowBackground =
- element.background.enabled &&
- element.background.color &&
- element.background.color !== "transparent";
- const backgroundColor = shouldShowBackground
- ? element.background.color
- : "transparent";
+ bg.enabled && bg.color && bg.color !== "transparent";
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const displayPaddingX = shouldShowBackground
+ ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
+ const displayPaddingY = shouldShowBackground
+ ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
return (
<div
className="absolute"
style={{
left: posX,
- top: posY - verticalAlignmentOffset,
- transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
+ top: posY - cssVisualCenterOffset,
+ transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`,
transformOrigin: "center center",
}}
>
@@ -131,13 +190,15 @@ export function TextEditOverlay({
fontWeight,
fontStyle,
textAlign: element.textAlign,
- letterSpacing: `${letterSpacing}px`,
+ letterSpacing: `${displayLetterSpacing}px`,
lineHeight,
color: element.color,
- backgroundColor,
- minHeight: displayFontSize * lineHeight,
+ backgroundColor: shouldShowBackground ? bg.color : "transparent",
+ minHeight: lineHeightPx,
textDecoration: element.textDecoration ?? "none",
- padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0,
+ padding: shouldShowBackground
+ ? `${displayPaddingY}px ${displayPaddingX}px`
+ : 0,
minWidth: 1,
}}
onInput={handleInput}
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
index 7f5cd9bc..56cb784a 100644
--- a/apps/web/src/components/editor/panels/preview/toolbar.tsx
+++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "@/lib/time";
import { invokeAction } from "@/lib/actions";
@@ -7,74 +8,144 @@ import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
+ GridTableIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { OcSocialIcon } from "@opencut/ui/icons";
+import { getGuideById } from "@/lib/guides";
import { Separator } from "@/components/ui/separator";
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+} from "@/components/ui/select";
+import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
+import { usePreviewViewport } from "./preview-viewport";
+import { GridPopover } from "./guide-popover";
+import { usePreviewStore } from "@/stores/preview-store";
export function PreviewToolbar({
- isFullscreen,
onToggleFullscreen,
}: {
- isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
- const editor = useEditor();
- const isPlaying = editor.playback.getIsPlaying();
- const currentTime = editor.playback.getCurrentTime();
- const totalDuration = editor.timeline.getTotalDuration();
- const fps = editor.project.getActive().settings.fps;
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const activeGuideDefinition = getGuideById(activeGuide);
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
- <div className="flex items-center">
- <EditableTimecode
- time={currentTime}
- duration={totalDuration}
- format="HH:MM:SS:FF"
- fps={fps}
- onTimeChange={({ time }) => editor.playback.seek({ time })}
- className="text-center"
- />
- <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
- <span className="text-muted-foreground font-mono text-xs">
- {formatTimeCode({
- timeInSeconds: totalDuration,
- format: "HH:MM:SS:FF",
- fps,
- })}
- </span>
- </div>
-
- <Button
- variant="text"
- size="icon"
- onClick={() => invokeAction("toggle-play")}
- >
- <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
- </Button>
-
+ <TimecodeDisplay />
+ <PlayPauseButton />
<div className="justify-self-end flex items-center gap-2.5">
- <Button
- variant="secondary"
- size="sm"
- className="[&_svg]:size-auto px-1 h-7"
- onClick={onToggleFullscreen}
- title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
- >
- <OcSocialIcon size={20} />
- </Button>
+ <ZoomSelect />
<Separator orientation="vertical" className="h-4" />
- <Button
- variant="text"
- onClick={onToggleFullscreen}
- title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
- >
+ <GridPopover>
+ <Button
+ variant={activeGuideDefinition ? "secondary" : "text"}
+ size="icon"
+ >
+ {activeGuideDefinition ? (
+ activeGuideDefinition.renderTriggerIcon()
+ ) : (
+ <HugeiconsIcon icon={GridTableIcon} />
+ )}
+ </Button>
+ </GridPopover>
+ <Button variant="text" onClick={onToggleFullscreen}>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
</div>
</div>
);
}
+
+function TimecodeDisplay() {
+ const editor = useEditor();
+ const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
+ const fps = useEditor((e) => e.project.getActive().settings.fps);
+ const [currentTime, setCurrentTime] = useState(() =>
+ editor.playback.getCurrentTime(),
+ );
+
+ useEffect(() => {
+ const handler = (e: Event) =>
+ setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
+ window.addEventListener("playback-update", handler);
+ window.addEventListener("playback-seek", handler);
+ return () => {
+ window.removeEventListener("playback-update", handler);
+ window.removeEventListener("playback-seek", handler);
+ };
+ }, []);
+
+ return (
+ <div className="flex items-center">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
+ <span className="text-muted-foreground font-mono text-xs">
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+ </span>
+ </div>
+ );
+}
+
+function ZoomSelect() {
+ const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
+ usePreviewViewport();
+
+ const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
+
+ const onValueChange = (value: string) => {
+ if (value === "fit") {
+ fitToScreen();
+ } else {
+ setViewportPercent({ percent: Number(value) });
+ }
+ };
+
+ return (
+ <Select
+ value={isAtFit ? "fit" : String(zoomPercent)}
+ onValueChange={onValueChange}
+ >
+ <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
+ <SelectContent>
+ <SelectItem value="fit">Fit</SelectItem>
+ <SelectSeparator />
+ {PREVIEW_ZOOM_PRESETS.map((preset) => (
+ <SelectItem key={preset} value={String(preset)}>
+ {preset}%
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
+
+function PlayPauseButton() {
+ const isPlaying = useEditor((e) => e.playback.getIsPlaying());
+
+ return (
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => invokeAction("toggle-play")}
+ >
+ <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
index 2fcf8096..88332625 100644
--- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx
+++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
@@ -1,112 +1,55 @@
"use client";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useTransformHandles } from "@/hooks/use-transform-handles";
-import { useEditor } from "@/hooks/use-editor";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import { SnapGuides } from "./snap-guides";
-import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ getCornerPosition,
+ getEdgeHandlePosition,
+ getRotationHandlePosition,
+ type Corner,
+ type Edge,
+} from "@/lib/preview/element-bounds";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
+import {
+ BoundingBoxOutline,
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ getResizeCursor,
+} from "./handle-primitives";
import { Rotate01Icon } from "@hugeicons/core-free-icons";
-const HANDLE_SIZE = 10;
-const ROTATION_HANDLE_OFFSET = 24;
-const ROTATION_HANDLE_RADIUS = 10;
-const CORNER_HIT_AREA_SIZE = 18;
-
-type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const CORNERS: Corner[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
];
-
-export function getCornerPosition({
- bounds,
- corner,
-}: {
- bounds: ElementBounds;
- corner: Corner;
-}): { x: number; y: number } {
- const halfW = bounds.width / 2;
- const halfH = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
-
- const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
- const localY =
- corner === "top-left" || corner === "top-right" ? -halfH : halfH;
-
- return {
- x: bounds.cx + (localX * cos - localY * sin),
- y: bounds.cy + (localX * sin + localY * cos),
- };
-}
-
-export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
- x: number;
- y: number;
-} {
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
- return {
- x: bounds.cx - localY * sin,
- y: bounds.cy + localY * cos,
- };
-}
-
-export function getOverlayContext({
- canvasRef,
- containerRef,
-}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
-}): {
- canvasRect: DOMRect;
- containerRect: DOMRect;
-} | null {
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
- if (!canvasRect || !containerRect) return null;
- return { canvasRect, containerRect };
-}
+const EDGES: Edge[] = ["right", "left", "bottom"];
export function TransformHandles({
- canvasRef,
- containerRef,
+ onSnapLinesChange,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
+ onSnapLinesChange?: OnSnapLinesChange;
}) {
+ const viewport = usePreviewViewport();
const {
selectedWithBounds,
hasVisualSelection,
- snapLines,
handleCornerPointerDown,
+ handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
- } = useTransformHandles({ canvasRef });
-
- const editor = useEditor();
- const canvasSize = editor.project.getActive().settings.canvasSize;
+ } = useTransformHandles({ onSnapLinesChange });
if (!hasVisualSelection || !selectedWithBounds) return null;
- const overlayContext = getOverlayContext({ canvasRef, containerRef });
- if (!overlayContext) return null;
-
const { bounds, element } = selectedWithBounds;
if (!isVisualElement(element)) return null;
- const { canvasRect, containerRect } = overlayContext;
- const displayScale = getDisplayScale({ canvasRect, canvasSize });
+ const displayScale = viewport.getDisplayScale();
const toOverlay = ({
canvasX,
@@ -115,31 +58,30 @@ export function TransformHandles({
canvasX: number;
canvasY: number;
}) =>
- canvasToOverlay({
+ viewport.canvasToOverlay({
canvasX,
canvasY,
- canvasRect,
- containerRect,
- canvasSize,
});
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
- const outlineWidth = bounds.width * displayScale.x;
- const outlineHeight = bounds.height * displayScale.y;
+ const outlineWidth = Math.abs(bounds.width) * displayScale.x;
+ const outlineHeight = Math.abs(bounds.height) * displayScale.y;
- const rotationHandle = getRotationHandlePosition({ bounds });
+ const rotationHandleCanvas = getRotationHandlePosition({ bounds });
const rotationHandleScreen = toOverlay({
- canvasX: rotationHandle.x,
- canvasY: rotationHandle.y,
+ canvasX: rotationHandleCanvas.x,
+ canvasY: rotationHandleCanvas.y,
});
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
return (
- <div className="pointer-events-none absolute inset-0" aria-hidden>
- <SnapGuides
- lines={snapLines}
- canvasRef={canvasRef}
- containerRef={containerRef}
- />
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
<BoundingBoxOutline
center={center}
outlineWidth={outlineWidth}
@@ -152,132 +94,47 @@ export function TransformHandles({
canvasX: cornerPosition.x,
canvasY: cornerPosition.y,
});
+ const angleDeg =
+ Math.atan2(screen.y - center.y, screen.x - center.x) *
+ (180 / Math.PI);
return (
<CornerHandle
key={corner}
- corner={corner}
+ cursor={getResizeCursor({ angleDeg })}
screen={screen}
onPointerDown={(event) =>
handleCornerPointerDown({ event, corner })
}
- onPointerMove={(event) => handlePointerMove({ event })}
- onPointerUp={(event) => handlePointerUp({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ {EDGES.map((edge) => {
+ const edgePosition = getEdgeHandlePosition({ bounds, edge });
+ const screen = toOverlay({
+ canvasX: edgePosition.x,
+ canvasY: edgePosition.y,
+ });
+ return (
+ <EdgeHandle
+ key={edge}
+ edge={edge}
+ screen={screen}
+ rotation={bounds.rotation}
+ onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
/>
);
})}
- <RotationHandle
+ <IconHandle
+ icon={Rotate01Icon}
screen={rotationHandleScreen}
onPointerDown={(event) => handleRotationPointerDown({ event })}
- onPointerMove={(event) => handlePointerMove({ event })}
- onPointerUp={(event) => handlePointerUp({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
/>
</div>
);
}
-
-function BoundingBoxOutline({
- center,
- outlineWidth,
- outlineHeight,
- rotation,
-}: {
- center: { x: number; y: number };
- outlineWidth: number;
- outlineHeight: number;
- rotation: number;
-}) {
- return (
- <div
- className="pointer-events-none absolute"
- style={{
- left: center.x - outlineWidth / 2,
- top: center.y - outlineHeight / 2,
- width: outlineWidth,
- height: outlineHeight,
- transform: `rotate(${rotation}deg)`,
- transformOrigin: "center center",
- border: "1px dashed white",
- boxSizing: "border-box",
- opacity: 0.75,
- }}
- />
- );
-}
-
-function CornerHandle({
- corner,
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- corner: Corner;
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- const isNesw = corner === "top-right" || corner === "bottom-left";
-
- return (
- <button
- type="button"
- className={cn(
- "absolute flex items-center justify-center outline-none",
- isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize",
- )}
- style={{
- left: screen.x - CORNER_HIT_AREA_SIZE / 2,
- top: screen.y - CORNER_HIT_AREA_SIZE / 2,
- width: CORNER_HIT_AREA_SIZE,
- height: CORNER_HIT_AREA_SIZE,
- pointerEvents: "auto",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
- <div
- className="rounded-sm bg-white"
- style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
- />
- </button>
- );
-}
-
-function RotationHandle({
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- return (
- <button
- type="button"
- className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none"
- style={{
- left: screen.x - ROTATION_HANDLE_RADIUS,
- top: screen.y - ROTATION_HANDLE_RADIUS,
- width: ROTATION_HANDLE_RADIUS * 2,
- height: ROTATION_HANDLE_RADIUS * 2,
- pointerEvents: "auto",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
- <HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} />
- </button>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
deleted file mode 100644
index 2ad46497..00000000
--- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export function AudioProperties() {
- return <div className="space-y-4 p-5">Audio properties</div>;
-}
diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
deleted file mode 100644
index a1324bdf..00000000
--- a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import type { Effect } from "@/types/effects";
-import type { VisualElement } from "@/types/timeline";
-import { getEffect } from "@/lib/effects/registry";
-import { useEditor } from "@/hooks/use-editor";
-import { usePropertiesStore } from "@/stores/properties-store";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
- SectionFields,
-} from "./section";
-import { EffectParamField } from "./effect-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowLeft01Icon,
- Delete02Icon,
- ViewIcon,
- ViewOffSlashIcon,
-} from "@hugeicons/core-free-icons";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { cn } from "@/utils/ui";
-export function ClipEffectsProperties({
- element,
- trackId,
-}: {
- element: VisualElement;
- trackId: string;
-}) {
- const closeClipEffects = usePropertiesStore(
- (state) => state.closeClipEffects,
- );
- const editor = useEditor();
- const effects = element.effects ?? [];
-
- useEffect(() => {
- if (effects.length === 0) closeClipEffects();
- }, [effects.length, closeClipEffects]);
-
- const [dragIndex, setDragIndex] = useState<number | null>(null);
- const [dropIndex, setDropIndex] = useState<number | null>(null);
-
- const handleDragStart = ({ index }: { index: number }) => {
- setDragIndex(index);
- };
-
- const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => {
- event.preventDefault();
- if (index !== dropIndex) setDropIndex(index);
- };
-
- const handleDrop = ({ toIndex }: { toIndex: number }) => {
- if (dragIndex !== null && dragIndex !== toIndex) {
- editor.timeline.reorderClipEffects({
- trackId,
- elementId: element.id,
- fromIndex: dragIndex,
- toIndex,
- });
- }
- setDragIndex(null);
- setDropIndex(null);
- };
-
- const handleDragEnd = () => {
- setDragIndex(null);
- setDropIndex(null);
- };
-
- return (
- <div className="flex h-full flex-col">
- <div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5">
- <Button
- variant="ghost"
- size="icon"
- onClick={closeClipEffects}
- aria-label="Back to properties"
- >
- <HugeiconsIcon icon={ArrowLeft01Icon} />
- </Button>
- <span className="text-sm font-medium">Effects</span>
- </div>
- <ScrollArea className="flex-1 scrollbar-hidden">
- {effects.map((effect, index) => (
- // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder
- <div
- key={effect.id}
- draggable
- onDragStart={() => handleDragStart({ index })}
- onDragOver={(event) => handleDragOver({ event, index })}
- onDrop={() => handleDrop({ toIndex: index })}
- onDragEnd={handleDragEnd}
- className={cn(
- "group",
- dragIndex === index && "opacity-40",
- dropIndex === index &&
- dragIndex !== null &&
- dragIndex !== index &&
- (index < dragIndex
- ? "border-t-2 border-primary"
- : "border-b-2 border-primary"),
- )}
- >
- <ClipEffectSection
- effect={effect}
- element={element}
- trackId={trackId}
- />
- </div>
- ))}
- </ScrollArea>
- </div>
- );
-}
-
-function ClipEffectSection({
- effect,
- element,
- trackId,
-}: {
- effect: Effect;
- element: VisualElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = getEffect({ effectType: effect.type });
-
- const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => {
- const updatedEffects = (element.effects ?? []).map((existing) =>
- existing.id !== effect.id
- ? existing
- : { ...existing, params: { ...existing.params, [key]: value } },
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { effects: updatedEffects },
- },
- ],
- });
- };
-
- const commitParam = () => editor.timeline.commitPreview();
-
- const toggleEffect = () =>
- editor.timeline.toggleClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- });
-
- const removeEffect = () =>
- editor.timeline.removeClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- });
-
- return (
- <Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}>
- <SectionHeader
- className="cursor-move"
- trailing={
- <div className="flex items-center gap-1">
- <Button
- variant={effect.enabled ? "secondary" : "ghost"}
- size="icon"
- aria-label={`Toggle ${definition.name}`}
- onClick={toggleEffect}
- >
- <HugeiconsIcon
- icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
- />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Remove ${definition.name}`}
- onClick={removeEffect}
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- </div>
- }
- >
- <SectionTitle
- className={cn(!effect.enabled && "text-muted-foreground")}
- >
- {definition.name}
- </SectionTitle>
- </SectionHeader>
- {effect.enabled && (
- <SectionContent>
- <SectionFields>
- {definition.params.map((param) => (
- <EffectParamField
- key={param.key}
- param={param}
- value={effect.params[param.key] ?? param.default}
- onPreview={previewParam({ key: param.key })}
- onCommit={commitParam}
- />
- ))}
- </SectionFields>
- </SectionContent>
- )}
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx
index b5b8685f..b5b8685f 100644
--- a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx
+++ b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx
diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
new file mode 100644
index 00000000..e69c6918
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
+import { formatNumberForDisplay, snapToStep } from "@/utils/math";
+import { SectionField } from "@/components/section";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { ColorPicker } from "@/components/ui/color-picker";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { KeyframeToggle } from "./keyframe-toggle";
+
+export function PropertyParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+ keyframe,
+}: {
+ param: ParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ keyframe?: {
+ isActive: boolean;
+ isDisabled: boolean;
+ onToggle: () => void;
+ };
+}) {
+ return (
+ <SectionField
+ label={param.label}
+ beforeLabel={
+ keyframe ? (
+ <KeyframeToggle
+ isActive={keyframe.isActive}
+ isDisabled={keyframe.isDisabled}
+ title={`Toggle ${param.label.toLowerCase()} keyframe`}
+ onToggle={keyframe.onToggle}
+ />
+ ) : undefined
+ }
+ >
+ <ParamInput
+ param={param}
+ value={value}
+ onPreview={onPreview}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ );
+}
+
+function ParamInput({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: ParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ if (param.type === "number") {
+ return (
+ <NumberParamField
+ param={param}
+ value={typeof value === "number" ? value : Number(value)}
+ onPreview={onPreview}
+ onCommit={onCommit}
+ />
+ );
+ }
+
+ if (param.type === "boolean") {
+ return (
+ <Switch
+ checked={Boolean(value)}
+ onCheckedChange={(checked) => {
+ onPreview(checked);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ if (param.type === "select") {
+ return (
+ <Select
+ value={String(value)}
+ onValueChange={(selected) => {
+ onPreview(selected);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {param.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+ }
+
+ if (param.type === "color") {
+ return (
+ <ColorPicker
+ value={String(value).replace(/^#/, "").toUpperCase()}
+ onChange={(color) => onPreview(`#${color}`)}
+ onChangeEnd={(color) => {
+ onPreview(`#${color}`);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ return null;
+}
+
+function NumberParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: NumberParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+}) {
+ const { min, max, step, displayMultiplier = 1 } = param;
+ const displayValue = value * displayMultiplier;
+ const clampDisplayValue = (nextDisplayValue: number) =>
+ Math.max(
+ min,
+ max !== undefined ? Math.min(max, nextDisplayValue) : nextDisplayValue,
+ );
+
+ const previewFromDisplay = (displayVal: number) => {
+ const clamped = clampDisplayValue(
+ snapToStep({ value: displayVal, step }),
+ );
+ onPreview(clamped / displayMultiplier);
+ };
+
+ const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
+
+ const draft = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: displayValue,
+ maxFractionDigits,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clampDisplayValue(snapToStep({ value: parsed, step }));
+ },
+ onPreview: previewFromDisplay,
+ onCommit,
+ });
+
+ const handleReset = () => {
+ onPreview(param.default);
+ onCommit();
+ };
+
+ return (
+ <NumberField
+ icon={param.shortLabel}
+ value={draft.displayValue}
+ dragSensitivity="slow"
+ isDefault={value === param.default}
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ onScrub={previewFromDisplay}
+ onScrubEnd={onCommit}
+ onReset={handleReset}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
deleted file mode 100644
index 408d430a..00000000
--- a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-"use client";
-
-import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects";
-import { clamp } from "@/utils/math";
-import { SectionField } from "./section";
-import { Slider } from "@/components/ui/slider";
-import { NumberField } from "@/components/ui/number-field";
-import { Switch } from "@/components/ui/switch";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { usePropertyDraft } from "./hooks/use-property-draft";
-
-export function EffectParamField({
- param,
- value,
- onPreview,
- onCommit,
-}: {
- param: EffectParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
- onCommit: () => void;
-}) {
- return (
- <SectionField label={param.label}>
- <EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} />
- </SectionField>
- );
-}
-
-function EffectParamInput({
- param,
- value,
- onPreview,
- onCommit,
-}: {
- param: EffectParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
- onCommit: () => void;
-}) {
- if (param.type === "number") {
- return (
- <NumberParamField
- param={param}
- value={typeof value === "number" ? value : Number(value)}
- onPreview={onPreview}
- onCommit={onCommit}
- />
- );
- }
-
- if (param.type === "boolean") {
- return (
- <Switch
- checked={Boolean(value)}
- onCheckedChange={(checked) => {
- onPreview(checked);
- onCommit();
- }}
- />
- );
- }
-
- if (param.type === "select") {
- return (
- <Select
- value={String(value)}
- onValueChange={(selected) => {
- onPreview(selected);
- onCommit();
- }}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {param.options.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- );
- }
-
- if (param.type === "color") {
- return (
- <input
- type="color"
- className="h-8 w-full cursor-pointer rounded border"
- value={String(value)}
- onChange={(event) => onPreview(event.target.value)}
- onBlur={onCommit}
- />
- );
- }
-
- return null;
-}
-
-function NumberParamField({
- param,
- value,
- onPreview,
- onCommit,
-}: {
- param: NumberEffectParamDefinition;
- value: number;
- onPreview: (value: number) => void;
- onCommit: () => void;
-}) {
- const { min, max, step } = param;
-
- const draft = usePropertyDraft({
- displayValue: String(value),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min, max });
- },
- onPreview,
- onCommit,
- });
-
- return (
- <div className="flex items-center gap-3">
- <Slider
- className="flex-1"
- min={min}
- max={max}
- step={step}
- value={[value]}
- onValueChange={([newValue]) => onPreview(newValue)}
- onValueCommit={onCommit}
- />
- <NumberField
- className="w-16 shrink-0"
- value={draft.displayValue}
- onFocus={draft.onFocus}
- onChange={draft.onChange}
- onBlur={draft.onBlur}
- />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
deleted file mode 100644
index 75fc30a1..00000000
--- a/apps/web/src/components/editor/panels/properties/effect-properties.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client";
-
-import type { EffectElement } from "@/types/timeline";
-import { getEffect } from "@/lib/effects/registry";
-import { useEditor } from "@/hooks/use-editor";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionFields,
- SectionTitle,
-} from "./section";
-import { EffectParamField } from "./effect-param-field";
-
-export function EffectProperties({
- element,
- trackId,
-}: {
- element: EffectElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = getEffect({ effectType: element.effectType });
-
- const previewParam =
- ({ key }: { key: string }) =>
- (value: number | string | boolean) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { params: { ...element.params, [key]: value } },
- },
- ],
- });
-
- return (
- <Section showTopBorder={false}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {definition.params.map((param) => (
- <EffectParamField
- key={param.key}
- param={param}
- value={element.params[param.key] ?? param.default}
- onPreview={previewParam({ key: param.key })}
- onCommit={() => editor.timeline.commitPreview()}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
index dbd19e4c..957ee1c9 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -4,8 +4,8 @@ import {
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
export function useKeyframedColorProperty({
trackId,
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
index 11aa049c..0f0168f8 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -4,8 +4,9 @@ import {
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
@@ -18,6 +19,7 @@ export function useKeyframedNumberProperty({
displayValue,
parse,
valueAtPlayhead,
+ step,
buildBaseUpdates,
}: {
trackId: string;
@@ -29,9 +31,12 @@ export function useKeyframedNumberProperty({
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
+ step?: number;
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
}) {
const editor = useEditor();
+ const snapValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
@@ -43,6 +48,7 @@ export function useKeyframedNumberProperty({
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
@@ -54,7 +60,7 @@ export function useKeyframedNumberProperty({
animations,
propertyPath,
time: localTime,
- value,
+ value: nextValue,
}),
},
},
@@ -68,7 +74,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
- updates: buildBaseUpdates({ value }),
+ updates: buildBaseUpdates({ value: nextValue }),
},
],
});
@@ -76,7 +82,10 @@ export function useKeyframedNumberProperty({
const propertyDraft = usePropertyDraft({
displayValue,
- parse,
+ parse: (input) => {
+ const parsedValue = parse(input);
+ return parsedValue === null ? null : snapValue(parsedValue);
+ },
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
@@ -107,13 +116,14 @@ export function useKeyframedNumberProperty({
elementId,
propertyPath,
time: localTime,
- value: valueAtPlayhead,
+ value: snapValue(valueAtPlayhead),
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
editor.timeline.upsertKeyframes({
keyframes: [
@@ -122,7 +132,7 @@ export function useKeyframedNumberProperty({
elementId,
propertyPath,
time: localTime,
- value,
+ value: nextValue,
},
],
});
@@ -134,7 +144,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
- updates: buildBaseUpdates({ value }),
+ updates: buildBaseUpdates({ value: nextValue }),
},
],
});
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
new file mode 100644
index 00000000..335e3fb6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -0,0 +1,148 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import {
+ buildGraphicParamPath,
+ getKeyframeAtTime,
+ getParamDefaultInterpolation,
+ getParamValueKind,
+ hasKeyframesForPath,
+ upsertPathKeyframe,
+} from "@/lib/animation";
+import type {
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { ParamDefinition } from "@/lib/params";
+import type { TimelineElement } from "@/lib/timeline";
+
+export interface KeyframedParamPropertyResult {
+ hasAnimatedKeyframes: boolean;
+ isKeyframedAtTime: boolean;
+ keyframeIdAtTime: string | null;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ toggleKeyframe: () => void;
+}
+
+export function useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId,
+ animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ buildBaseUpdates,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number | string | boolean;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: number | string | boolean;
+ }) => Partial<TimelineElement>;
+}): KeyframedParamPropertyResult {
+ const editor = useEditor();
+ const propertyPath = buildGraphicParamPath({ paramKey: param.key });
+ const hasAnimatedKeyframes = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time: localTime,
+ })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value,
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({
+ param,
+ }),
+ numericRange:
+ param.type === "number"
+ ? { min: param.min, max: param.max, step: param.step }
+ : undefined,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ return {
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ onPreview: previewValue,
+ onCommit: () => editor.timeline.commitPreview(),
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
new file mode 100644
index 00000000..188f05cb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
@@ -0,0 +1,190 @@
+import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ VectorValue,
+} from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedVectorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ displayX,
+ displayY,
+ parseComponent,
+ step,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: VectorValue;
+ displayX: string;
+ displayY: string;
+ parseComponent: (input: string) => number | null;
+ step?: number;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: VectorValue;
+ }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+ const snapComponentValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewVector = ({ value }: { value: VectorValue }) => {
+ const nextValue = {
+ x: snapComponentValue(value.x),
+ y: snapComponentValue(value.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: nextValue,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }],
+ });
+ };
+
+ const x = usePropertyDraft({
+ displayValue: displayX,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (xVal) =>
+ previewVector({ value: { x: xVal, y: resolvedValue.y } }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const y = usePropertyDraft({
+ displayValue: displayY,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (yVal) =>
+ previewVector({ value: { x: resolvedValue.x, y: yVal } }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ const commitX = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(value),
+ y: snapComponentValue(resolvedValue.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ const commitY = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(resolvedValue.x),
+ y: snapComponentValue(value),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ return {
+ x,
+ y,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitX,
+ commitY,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index cb45996d..3f3ff1f3 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -1,86 +1,95 @@
"use client";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { AudioProperties } from "./audio-properties";
-import { VideoProperties } from "./video-properties";
-import { TextProperties } from "./text-properties";
-import { EffectProperties } from "./effect-properties";
-import { ClipEffectsProperties } from "./clip-effects-properties";
-import { EmptyView } from "./empty-view";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { usePropertiesStore } from "@/stores/properties-store";
-import { isVisualElement } from "@/lib/timeline";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
-
-function ElementProperties({
- track,
- element,
-}: {
- track: TimelineTrack;
- element: TimelineElement;
-}) {
- if (element.type === "text") {
- return <TextProperties element={element} trackId={track.id} />;
- }
- if (element.type === "audio") {
- return <AudioProperties />;
- }
- if (
- element.type === "video" ||
- element.type === "image" ||
- element.type === "sticker"
- ) {
- return <VideoProperties element={element} trackId={track.id} />;
- }
- if (element.type === "effect") {
- return <EffectProperties element={element} trackId={track.id} />;
- }
- return null;
-}
+import { usePropertiesStore } from "./stores/properties-store";
+import { getPropertiesConfig } from "./registry";
+import { cn } from "@/utils/ui";
+import { EmptyView } from "./empty-view";
export function PropertiesPanel() {
const editor = useEditor();
+ useEditor((e) => e.timeline.getTracks());
+ useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
- const clipEffectsTarget = usePropertiesStore(
- (state) => state.clipEffectsTarget,
- );
+ const { activeTabPerType, setActiveTab } = usePropertiesStore();
- const clipEffectsTrack = clipEffectsTarget
- ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId })
- : null;
- const clipEffectsElement = clipEffectsTrack?.elements.find(
- (element) => element.id === clipEffectsTarget?.elementId,
- );
- const isShowingClipEffects =
- clipEffectsTrack &&
- clipEffectsElement &&
- isVisualElement(clipEffectsElement);
+ if (selectedElements.length === 0) {
+ return (
+ <div className="panel bg-background flex h-full flex-col items-center justify-center overflow-hidden rounded-sm border">
+ <EmptyView />
+ </div>
+ );
+ }
+
+ if (selectedElements.length > 1) {
+ return (
+ <div className="panel bg-background flex h-full flex-col items-center justify-center overflow-hidden rounded-sm border">
+ <p className="text-muted-foreground text-sm">
+ {selectedElements.length} elements selected.0
+ </p>
+ </div>
+ );
+ }
+
+ const mediaAssets = editor.media.getAssets();
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: selectedElements,
});
+ const elementWithTrack = elementsWithTracks[0];
+
+ if (!elementWithTrack) return null;
+
+ const { element, track } = elementWithTrack;
+ const config = getPropertiesConfig({ element, mediaAssets });
+ const visibleTabs = config.tabs;
+
+ const storedTabId = activeTabPerType[element.type];
+ const isStoredTabVisible = visibleTabs.some((t) => t.id === storedTabId);
+ const activeTabId = isStoredTabVisible ? storedTabId : config.defaultTab;
+ const activeTab =
+ visibleTabs.find((t) => t.id === activeTabId) ?? visibleTabs[0];
+
+ if (!activeTab) return null;
return (
- <div className="panel bg-background h-full rounded-sm border overflow-hidden">
- {isShowingClipEffects ? (
- <ClipEffectsProperties
- element={clipEffectsElement}
- trackId={clipEffectsTrack.id}
- />
- ) : selectedElements.length > 0 ? (
- <ScrollArea className="h-full scrollbar-hidden">
- {elementsWithTracks.map(({ track, element }) => (
- <ElementProperties
- key={element.id}
- track={track}
- element={element}
- />
+ <div className="panel bg-background flex h-full overflow-hidden rounded-sm border">
+ <TooltipProvider delayDuration={0}>
+ <div className="flex shrink-0 flex-col gap-0.5 border-r p-1 scrollbar-hidden overflow-y-auto">
+ {visibleTabs.map((tab) => (
+ <Tooltip key={tab.id}>
+ <TooltipTrigger asChild>
+ <Button
+ variant={tab.id === activeTab.id ? "secondary" : "ghost"}
+ size="icon"
+ onClick={() => setActiveTab(element.type, tab.id)}
+ aria-label={tab.label}
+ className={cn(
+ "shrink-0",
+ "h-8 w-8",
+ tab.id !== activeTab.id && "text-muted-foreground",
+ )}
+ >
+ {tab.icon}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right">{tab.label}</TooltipContent>
+ </Tooltip>
))}
- </ScrollArea>
- ) : (
- <EmptyView />
- )}
+ </div>
+ </TooltipProvider>
+ <ScrollArea className="flex-1 scrollbar-hidden">
+ {activeTab.content({ trackId: track.id })}
+ </ScrollArea>
</div>
);
}
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx
new file mode 100644
index 00000000..706faef4
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/registry.tsx
@@ -0,0 +1,303 @@
+import type { ReactNode } from "react";
+import type {
+ EffectElement,
+ GraphicElement,
+ ImageElement,
+ MaskableElement,
+ RetimableElement,
+ StickerElement,
+ TextElement,
+ VisualElement,
+ VideoElement,
+ AudioElement,
+ TimelineElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ TextFontIcon,
+ ArrowExpandIcon,
+ RainDropIcon,
+ MusicNote03Icon,
+ MagicWand05Icon,
+ DashboardSpeed02Icon,
+} from "@hugeicons/core-free-icons";
+import { TransformTab } from "./tabs/transform-tab";
+import { BlendingTab } from "./tabs/blending-tab";
+import { AudioTab } from "./tabs/audio-tab";
+import { TextTab } from "./tabs/text-tab";
+import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
+import { MasksTab } from "./tabs/masks-tab";
+import { SpeedTab } from "./tabs/speed-tab";
+import { GraphicTab } from "./tabs/graphic-tab";
+import { OcShapesIcon } from "@opencut/ui/icons";
+
+export type TabContentProps = {
+ trackId: string;
+};
+
+export type PropertiesTabDef = {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ content: (props: TabContentProps) => ReactNode;
+};
+
+export type ElementPropertiesConfig = {
+ defaultTab: string;
+ tabs: PropertiesTabDef[];
+};
+
+function buildTransformTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "transform",
+ label: "Transform",
+ icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
+ content: ({ trackId }) => (
+ <TransformTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildBlendingTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "blending",
+ label: "Blending",
+ icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
+ content: ({ trackId }) => (
+ <BlendingTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildAudioTab({
+ element,
+}: {
+ element: AudioElement | VideoElement;
+}): PropertiesTabDef {
+ return {
+ id: "audio",
+ label: "Audio",
+ icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
+ content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildSpeedTab({
+ element,
+}: {
+ element: RetimableElement;
+}): PropertiesTabDef {
+ return {
+ id: "speed",
+ label: "Speed",
+ icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
+ content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildMasksTab({
+ element,
+}: {
+ element: MaskableElement;
+}): PropertiesTabDef {
+ return {
+ id: "masks",
+ label: "Masks",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildClipEffectsTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <ClipEffectsTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
+ return {
+ id: "text",
+ label: "Text",
+ icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
+ content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildGraphicTab({
+ element,
+}: {
+ element: GraphicElement;
+}): PropertiesTabDef {
+ return {
+ id: "graphic",
+ label: "Graphic",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildStandaloneEffectTab({
+ element,
+}: {
+ element: EffectElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <StandaloneEffectTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function getTextConfig({
+ element,
+}: {
+ element: TextElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "text",
+ tabs: [
+ buildTextTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ ],
+ };
+}
+
+function getVideoConfig({
+ element,
+ mediaAsset,
+}: {
+ element: VideoElement;
+ mediaAsset: MediaAsset | undefined;
+}): ElementPropertiesConfig {
+ const showAudioTab = mediaAsset?.hasAudio !== false;
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ ...(showAudioTab ? [buildAudioTab({ element })] : []),
+ buildSpeedTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getImageConfig({
+ element,
+}: {
+ element: ImageElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getStickerConfig({
+ element,
+}: {
+ element: StickerElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getGraphicConfig({
+ element,
+}: {
+ element: GraphicElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "graphic",
+ tabs: [
+ buildGraphicTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getAudioConfig({
+ element,
+}: {
+ element: AudioElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "audio",
+ tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
+ };
+}
+
+function getEffectConfig({
+ element,
+}: {
+ element: EffectElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "effects",
+ tabs: [buildStandaloneEffectTab({ element })],
+ };
+}
+
+export function getPropertiesConfig({
+ element,
+ mediaAssets,
+}: {
+ element: TimelineElement;
+ mediaAssets: MediaAsset[];
+}): ElementPropertiesConfig {
+ switch (element.type) {
+ case "text":
+ return getTextConfig({ element });
+ case "video": {
+ const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
+ return getVideoConfig({ element, mediaAsset });
+ }
+ case "image":
+ return getImageConfig({ element });
+ case "sticker":
+ return getStickerConfig({ element });
+ case "graphic":
+ return getGraphicConfig({ element });
+ case "audio":
+ return getAudioConfig({ element });
+ case "effect":
+ return getEffectConfig({ element });
+ }
+}
diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx
deleted file mode 100644
index 8be58f39..00000000
--- a/apps/web/src/components/editor/panels/properties/sections/index.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./transform";
-export * from "./blending";
diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
deleted file mode 100644
index 6aaaa1fe..00000000
--- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx
+++ /dev/null
@@ -1,382 +0,0 @@
-import { NumberField } from "@/components/ui/number-field";
-import { useEditor } from "@/hooks/use-editor";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import type { AnimationPropertyPath } from "@/types/animation";
-import type { VisualElement } from "@/types/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "../section";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Link05Icon,
- RotateClockwiseIcon,
-} from "@hugeicons/core-free-icons";
-import { useState } from "react";
-import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
-import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
-import { KeyframeToggle } from "../keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-
-export function parseNumericInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : parsed;
-}
-
-export function isPropertyAtDefault({
- hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue,
- staticValue,
- defaultValue,
-}: {
- hasAnimatedKeyframes: boolean;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: number;
- staticValue: number;
- defaultValue: number;
-}): boolean {
- if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
- return isNearlyEqual({
- leftValue: resolvedValue,
- rightValue: defaultValue,
- });
- }
-
- return staticValue === defaultValue;
-}
-
-export function TransformSection({
- element,
- trackId,
- showTopBorder = true,
-}: {
- element: VisualElement;
- trackId: string;
- showTopBorder?: boolean;
-}) {
- const editor = useEditor();
- const [isScaleLocked, setIsScaleLocked] = useState(false);
- const playheadTime = editor.playback.getCurrentTime();
- const localTime = getElementLocalTime({
- timelineTime: playheadTime,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- const isPlayheadWithinElementRange =
- playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
- playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
-
- const positionX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.position.x",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.x).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.x,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- x: value,
- },
- },
- }),
- });
-
- const positionY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.position.y",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.y).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.y,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- y: value,
- },
- },
- }),
- });
-
- const scale = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scale",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scale * 100).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return Math.max(parsed, 1) / 100;
- },
- valueAtPlayhead: resolvedTransform.scale,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scale: value,
- },
- }),
- });
- const scaleFieldProps = {
- className: "flex-1",
- value: scale.displayValue,
- onFocus: scale.onFocus,
- onChange: scale.onChange,
- onBlur: scale.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scale.scrubTo,
- onScrubEnd: scale.commitScrub,
- onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scale,
- staticValue: element.transform.scale,
- defaultValue: DEFAULT_TRANSFORM.scale,
- }),
- };
-
- const rotation = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.rotate",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.rotate).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return clamp({ value: parsed, min: -360, max: 360 });
- },
- valueAtPlayhead: resolvedTransform.rotate,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- rotate: value,
- },
- }),
- });
-
- const hasPositionKeyframe =
- positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
-
- const togglePositionKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
- const keyframesToRemove: Array<{
- trackId: string;
- elementId: string;
- propertyPath: AnimationPropertyPath;
- keyframeId: string;
- }> = [];
- if (positionX.keyframeIdAtTime) {
- keyframesToRemove.push({
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.x" as const,
- keyframeId: positionX.keyframeIdAtTime,
- });
- }
- if (positionY.keyframeIdAtTime) {
- keyframesToRemove.push({
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.y" as const,
- keyframeId: positionY.keyframeIdAtTime,
- });
- }
-
- editor.timeline.removeKeyframes({
- keyframes: keyframesToRemove,
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.x",
- time: localTime,
- value: resolvedTransform.position.x,
- },
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.y",
- time: localTime,
- value: resolvedTransform.position.y,
- },
- ],
- });
- };
-
- return (
- <Section
- collapsible
- sectionKey={`${element.type}:transform`}
- showTopBorder={showTopBorder}
- >
- <SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Scale"
- beforeLabel={
- <KeyframeToggle
- isActive={scale.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle scale keyframe"
- onToggle={scale.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- {isScaleLocked ? (
- <>
- <NumberField icon="W" {...scaleFieldProps} />
- <NumberField icon="H" {...scaleFieldProps} />
- </>
- ) : (
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldProps}
- className="flex-1"
- />
- )}
- <Button
- type="button"
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- </div>
- </SectionField>
- <SectionField
- label="Position"
- beforeLabel={
- <KeyframeToggle
- isActive={hasPositionKeyframe}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle position keyframe"
- onToggle={togglePositionKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon="X"
- className="flex-1"
- value={positionX.displayValue}
- onFocus={positionX.onFocus}
- onChange={positionX.onChange}
- onBlur={positionX.onBlur}
- onScrub={positionX.scrubTo}
- onScrubEnd={positionX.commitScrub}
- onReset={() =>
- positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.x,
- staticValue: element.transform.position.x,
- defaultValue: DEFAULT_TRANSFORM.position.x,
- })}
- />
- <NumberField
- icon="Y"
- className="flex-1"
- value={positionY.displayValue}
- onFocus={positionY.onFocus}
- onChange={positionY.onChange}
- onBlur={positionY.onBlur}
- onScrub={positionY.scrubTo}
- onScrubEnd={positionY.commitScrub}
- onReset={() =>
- positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.y,
- staticValue: element.transform.position.y,
- defaultValue: DEFAULT_TRANSFORM.position.y,
- })}
- />
- </div>
- </SectionField>
-
- <SectionField
- label="Rotation"
- beforeLabel={
- <KeyframeToggle
- isActive={rotation.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle rotation keyframe"
- onToggle={rotation.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-none"
- value={rotation.displayValue}
- onFocus={rotation.onFocus}
- onChange={rotation.onChange}
- onBlur={rotation.onBlur}
- dragSensitivity="slow"
- onScrub={rotation.scrubTo}
- onScrubEnd={rotation.commitScrub}
- onReset={() =>
- rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.rotate,
- staticValue: element.transform.rotate,
- defaultValue: DEFAULT_TRANSFORM.rotate,
- })}
- />
- </div>
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
new file mode 100644
index 00000000..1783e3fc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
@@ -0,0 +1,18 @@
+import { create } from "zustand";
+
+interface PropertiesState {
+ activeTabPerType: Record<string, string>;
+ setActiveTab: (elementType: string, tabId: string) => void;
+ isTransformScaleLocked: boolean;
+ setTransformScaleLocked: (locked: boolean) => void;
+}
+
+export const usePropertiesStore = create<PropertiesState>()((set) => ({
+ activeTabPerType: {},
+ setActiveTab: (elementType, tabId) =>
+ set((state) => ({
+ activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
+ })),
+ isTransformScaleLocked: false,
+ setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+}));
diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
new file mode 100644
index 00000000..41b6ec6d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
@@ -0,0 +1,109 @@
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import { resolveNumberAtTime } from "@/lib/animation";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: resolvedVolume.toFixed(1),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: 0.1,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
index a50e84e7..c360817c 100644
--- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
@@ -1,13 +1,16 @@
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
-import {
- DEFAULT_BLEND_MODE,
- DEFAULT_OPACITY,
-} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
-import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
import {
Select,
SelectContent,
@@ -16,16 +19,17 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import type { BlendMode } from "@/types/rendering";
-import type { ElementType } from "@/types/timeline";
-import type { ElementAnimations } from "@/types/animation";
+import type { BlendMode } from "@/lib/rendering";
+import type { ElementType } from "@/lib/timeline";
+import type { ElementAnimations } from "@/lib/animation/types";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
-import { KeyframeToggle } from "../keyframe-toggle";
+import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation";
-import { isPropertyAtDefault } from "./transform";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { isPropertyAtDefault } from "./transform-tab";
type BlendingElement = {
id: string;
@@ -67,7 +71,7 @@ const BLEND_MODE_GROUPS = [
],
];
-export function BlendingSection({
+export function BlendingTab({
element,
trackId,
}: {
@@ -75,13 +79,19 @@ export function BlendingSection({
trackId: string;
}) {
const editor = useEditor();
- const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
- const didSelectRef = useRef(false);
+ const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
+ const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
const committedBlendModeRef = useRef(blendMode);
- if (!editor.timeline.isPreviewActive()) {
+ if (!isPreviewActive) {
committedBlendModeRef.current = blendMode;
}
+ const {
+ onPointerLeave,
+ onOpenChange: handleBlendModeOpenChange,
+ markCommitted,
+ } = useMenuPreview();
+
const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
@@ -103,14 +113,7 @@ export function BlendingSection({
],
});
}
- didSelectRef.current = true;
- };
-
- const handleBlendModeOpenChange = (isOpen: boolean) => {
- if (!isOpen) {
- if (!didSelectRef.current) editor.timeline.discardPreview();
- didSelectRef.current = false;
- }
+ markCommitted();
};
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
@@ -137,86 +140,91 @@ export function BlendingSection({
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
valueAtPlayhead: resolvedOpacity,
+ step: 0.01,
buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
- <Section collapsible sectionKey={`${element.type}:blending`}>
- <SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField
- label="Opacity"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={opacity.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle opacity keyframe"
- onToggle={opacity.toggleKeyframe}
- />
- }
- >
- <NumberField
- className="w-full"
- icon={
- <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ <Section collapsible sectionKey={`${element.id}:blending`}>
+ <SectionHeader>
+ <SectionTitle>Blending</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="Opacity"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={opacity.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle opacity keyframe"
+ onToggle={opacity.toggleKeyframe}
+ />
}
- value={opacity.displayValue}
- min={0}
- max={100}
- onFocus={opacity.onFocus}
- onChange={opacity.onChange}
- onBlur={opacity.onBlur}
- onScrub={opacity.scrubTo}
- onScrubEnd={opacity.commitScrub}
- onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOpacity,
- staticValue: element.opacity,
- defaultValue: DEFAULT_OPACITY,
- })}
- dragSensitivity="slow"
- />
- </SectionField>
- <SectionField label="Blend mode" className="w-1/2">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
>
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
+ <NumberField
className="w-full"
+ icon={
+ <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ }
+ value={opacity.displayValue}
+ min={0}
+ max={100}
+ onFocus={opacity.onFocus}
+ onChange={opacity.onChange}
+ onBlur={opacity.onBlur}
+ onScrub={opacity.scrubTo}
+ onScrubEnd={opacity.commitScrub}
+ onReset={() =>
+ opacity.commitValue({ value: DEFAULTS.element.opacity })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOpacity,
+ staticValue: element.opacity,
+ defaultValue: DEFAULTS.element.opacity,
+ })}
+ dragSensitivity="slow"
+ />
+ </SectionField>
+ <SectionField label="Blend mode" className="w-1/2">
+ <Select
+ value={committedBlendModeRef.current}
+ onOpenChange={handleBlendModeOpenChange}
+ onValueChange={commitBlendMode}
>
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36">
- {BLEND_MODE_GROUPS.map((group, groupIndex) => (
- <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
- {group.map((option) => (
- <SelectItem
- key={option.value}
- value={option.value}
- onPointerEnter={() =>
- previewBlendMode({ value: option.value as BlendMode })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
+ <SelectTrigger
+ icon={<HugeiconsIcon icon={RainDropIcon} />}
+ className="w-full"
+ >
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
+ {BLEND_MODE_GROUPS.map((group, groupIndex) => (
+ <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
+ {group.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ onPointerEnter={() =>
+ previewBlendMode({ value: option.value as BlendMode })
+ }
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+ <SelectSeparator />
+ ) : null}
+ </Fragment>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </div>
+ </SectionContent>
</Section>
);
}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
new file mode 100644
index 00000000..1b94ee2d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import { useState } from "react";
+import type { ParamValues } from "@/lib/params";
+import type { Effect } from "@/lib/effects/types";
+import type { EffectElement, VisualElement } from "@/lib/timeline";
+import { effectsRegistry } from "@/lib/effects";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ MagicWand05Icon,
+} from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { Separator } from "@/components/ui/separator";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+
+export function StandaloneEffectTab({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effect: Effect = {
+ id: element.id,
+ type: element.effectType,
+ params: element.params,
+ enabled: true,
+ };
+
+ const previewParam = (key: string) => (value: number | string | boolean) => {
+ previewUpdates({
+ params: { ...(renderElement as EffectElement).params, [key]: value },
+ });
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effect</SectionTitle>
+ </div>
+ <EffectSection
+ effect={effect}
+ renderParams={(renderElement as EffectElement).params}
+ previewParam={previewParam}
+ onCommit={commit}
+ />
+ </div>
+ );
+}
+
+export function ClipEffectsTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effects: Effect[] = element.effects ?? [];
+
+ const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
+ return (
+ (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
+ ?.params ??
+ effects.find((ef) => ef.id === effectId)?.params ??
+ {}
+ );
+ };
+
+ const buildPreviewParam =
+ (effectId: string) =>
+ (key: string) =>
+ (value: number | string | boolean) => {
+ const updatedEffects = (
+ (renderElement as VisualElement).effects ?? []
+ ).map((existing) =>
+ existing.id !== effectId
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ previewUpdates({ effects: updatedEffects });
+ };
+
+ const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
+
+ const handleDragOver = ({
+ event,
+ index,
+ }: {
+ event: React.DragEvent;
+ index: number;
+ }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effects</SectionTitle>
+ </div>
+ {effects.length === 0 ? (
+ <EmptyView />
+ ) : (
+ <ul className="flex flex-col">
+ {effects.map((effect, index) => {
+ const resolvedDragIndex = dragIndex ?? -1;
+ const isDragging = dragIndex === index;
+ const isDropTarget =
+ dropIndex === index && dragIndex !== null && dragIndex !== index;
+ const showTopDropIndicator =
+ isDropTarget && index < resolvedDragIndex;
+ const showBottomDropIndicator =
+ isDropTarget && index > resolvedDragIndex;
+
+ return (
+ <li
+ key={effect.id}
+ draggable
+ onDragStart={() => handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group list-none",
+ isDragging && "opacity-40",
+ showTopDropIndicator && "border-t-2 border-primary",
+ showBottomDropIndicator && "border-b-2 border-primary",
+ )}
+ >
+ <EffectSection
+ effect={effect}
+ renderParams={getRenderParams({ effectId: effect.id })}
+ previewParam={buildPreviewParam(effect.id)}
+ onCommit={commit}
+ onToggle={() =>
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ onRemove={() =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ />
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+function EmptyView() {
+ const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
+
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-10 text-muted-foreground"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No effects</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-44">
+ Add effects to this layer from the Assets panel.
+ </p>
+ </div>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setActiveTab("effects")}
+ >
+ Open effects
+ </Button>
+ </div>
+ );
+}
+
+function EffectSection({
+ effect,
+ renderParams,
+ previewParam,
+ onCommit,
+ onToggle,
+ onRemove,
+}: {
+ effect: Effect;
+ renderParams: ParamValues;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+ onToggle?: () => void;
+ onRemove?: () => void;
+}) {
+ const definition = effectsRegistry.get(effect.type);
+
+ return (
+ <Section
+ sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
+ showTopBorder={false}
+ >
+ <SectionHeader
+ className={cn(onToggle && "cursor-move")}
+ trailing={
+ onToggle && (
+ <div className="flex items-center gap-1">
+ <Button
+ variant={effect.enabled ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={`Toggle ${definition.name}`}
+ onClick={onToggle}
+ >
+ <HugeiconsIcon
+ icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name}`}
+ onClick={onRemove}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ )
+ }
+ >
+ <SectionTitle
+ className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
+ >
+ {definition.name}
+ </SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
+ >
+ <SectionFields>
+ {definition.params.map((param) => (
+ <div key={param.key} className="flex flex-col gap-3.5">
+ <div className="px-4">
+ <PropertyParamField
+ param={param}
+ value={renderParams[param.key] ?? param.default}
+ onPreview={previewParam(param.key)}
+ onCommit={onCommit}
+ />
+ </div>
+ <Separator />
+ </div>
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
new file mode 100644
index 00000000..22dae8cc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "../hooks/use-keyframed-param-property";
+import { resolveGraphicParamsAtTime } from "@/lib/animation";
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+import type { GraphicElement } from "@/lib/timeline";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && (
+ <StrokeSection element={element} trackId={trackId} />
+ )}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ });
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
new file mode 100644
index 00000000..6a5d0d0b
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
@@ -0,0 +1,660 @@
+"use client";
+
+import type { MaskableElement } from "@/lib/timeline";
+import type { Mask, MaskType } from "@/lib/masks/types";
+import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
+import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ FeatherIcon,
+ PlusSignIcon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { ColorPicker } from "@/components/ui/color-picker";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { NumberField } from "@/components/ui/number-field";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
+import { cn } from "@/utils/ui";
+
+type MasksTabProps = {
+ element: MaskableElement;
+ trackId: string;
+};
+
+type MaskItemProps = {
+ trackId: string;
+ elementId: string;
+ mask: Mask;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+};
+
+type EmptyViewProps = {
+ onAddMask: () => void;
+};
+
+type PreviewParamHandler = (
+ key: string,
+) => (value: number | string | boolean) => void;
+
+type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
+
+export function MasksTab({ element, trackId }: MasksTabProps) {
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } =
+ useElementPreview<MaskableElement>({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+ const maskDefs = masksRegistry.getAll();
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const masks = element.masks ?? [];
+ const renderMasks = renderElement.masks ?? masks;
+ const hasMask = masks.length > 0;
+ const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const elementBounds = useMemo(() => {
+ const clampedTime = Math.min(
+ Math.max(currentTime, element.startTime),
+ element.startTime + element.duration - TIME_EPSILON_SECONDS,
+ );
+
+ return (
+ getVisibleElementsWithBounds({
+ tracks,
+ currentTime: clampedTime,
+ canvasSize,
+ mediaAssets,
+ }).find(
+ (item) => item.trackId === trackId && item.elementId === element.id,
+ )?.bounds ?? null
+ );
+ }, [
+ canvasSize,
+ currentTime,
+ element.duration,
+ element.id,
+ element.startTime,
+ mediaAssets,
+ trackId,
+ tracks,
+ ]);
+
+ const handleDropdownOpenChange = (open: boolean) => {
+ if (hasMask && open) return;
+ setIsDropdownOpen(open);
+ onOpenChange(open);
+ };
+
+ const previewMask = ({ maskType }: { maskType: MaskType }) => {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ };
+
+ const commitMask = ({ maskType }: { maskType: MaskType }) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ }
+ markCommitted();
+ setIsDropdownOpen(false);
+ };
+
+ const previewMaskParam =
+ ({ index, key }: { index: number; key: string }) =>
+ (value: number | string | boolean) => {
+ if (!renderMasks[index]) {
+ return;
+ }
+
+ const updatedMasks = renderMasks.map((existingMask, maskIndex) =>
+ maskIndex !== index
+ ? existingMask
+ : {
+ ...existingMask,
+ params: {
+ ...existingMask.params,
+ [key]: value,
+ },
+ },
+ );
+
+ previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2">
+ <SectionTitle>Masks</SectionTitle>
+ <DropdownMenu
+ open={hasMask ? false : isDropdownOpen}
+ onOpenChange={handleDropdownOpenChange}
+ >
+ {hasMask ? (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="inline-flex">
+ <Button
+ variant="ghost"
+ size="icon"
+ disabled
+ aria-label="Add mask"
+ >
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
+ </Button>
+ </span>
+ </TooltipTrigger>
+ <TooltipContent className="max-w-56 text-balance">
+ Only one mask is supported right now. If you need more,
+ duplicate the clip and apply a different mask to each copy.
+ </TooltipContent>
+ </Tooltip>
+ ) : (
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon" aria-label="Add mask">
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
+ </Button>
+ </DropdownMenuTrigger>
+ )}
+ <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}>
+ {maskDefs.map((definition) => (
+ <DropdownMenuItem
+ key={definition.type}
+ onPointerEnter={() =>
+ previewMask({ maskType: definition.type })
+ }
+ onClick={() => commitMask({ maskType: definition.type })}
+ >
+ <HugeiconsIcon {...definition.icon} />
+ {definition.name}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {masks.length === 0 ? (
+ <EmptyView onAddMask={() => setIsDropdownOpen(true)} />
+ ) : (
+ masks.map((mask, index) => (
+ <MaskItem
+ key={mask.id}
+ trackId={trackId}
+ elementId={element.id}
+ mask={renderMasks[index] ?? mask}
+ previewParam={(paramKey) =>
+ previewMaskParam({ index, key: paramKey })
+ }
+ onCommit={commit}
+ />
+ ))
+ )}
+ </div>
+ );
+}
+
+function MaskItem({
+ trackId,
+ elementId,
+ mask,
+ previewParam,
+ onCommit,
+}: MaskItemProps) {
+ const editor = useEditor();
+ const definition = masksRegistry.get(mask.type);
+
+ return (
+ <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}>
+ <SectionHeader
+ trailing={
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Toggle ${definition.name} mask inversion`}
+ onClick={() =>
+ editor.timeline.toggleMaskInverted({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+ <OcMirrorIcon
+ className={cn(mask.params.inverted && "-scale-x-100")}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name} mask`}
+ onClick={() =>
+ editor.timeline.removeMask({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ }
+ >
+ <div className="flex items-center gap-2">
+ <HugeiconsIcon {...definition.icon} size={14} />
+ <SectionTitle className="capitalize font-normal">
+ {definition.name}
+ </SectionTitle>
+ </div>
+ </SectionHeader>
+ <SectionContent>
+ <MaskParamsFields
+ mask={mask}
+ definition={definition}
+ previewParam={previewParam}
+ onCommit={onCommit}
+ />
+ </SectionContent>
+ </Section>
+ );
+}
+
+function MaskParamsFields({
+ mask,
+ definition,
+ previewParam,
+ onCommit,
+}: {
+ mask: Mask;
+ definition: RegisteredMaskDefinition;
+ previewParam: PreviewParamHandler;
+ onCommit: () => void;
+}) {
+ const featherParam = getNumberParamDefinition({
+ definition,
+ key: "feather",
+ });
+ const strokeWidthParam = getNumberParamDefinition({
+ definition,
+ key: "strokeWidth",
+ });
+ const previewNumberParam = (key: string) => (value: number) =>
+ previewParam(key)(value);
+ const previewStrokeColor = previewParam("strokeColor");
+ const strokeAlignParam = definition.params.find(
+ (param): param is SelectParamDefinition =>
+ param.key === "strokeAlign" && param.type === "select",
+ );
+
+ return (
+ <SectionFields>
+ {definition.features.hasPosition &&
+ "centerX" in mask.params &&
+ "centerY" in mask.params && (
+ <SectionField label="Position">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="X"
+ param={getNumberParamDefinition({
+ definition,
+ key: "centerX",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "centerX",
+ })}
+ onPreview={previewNumberParam("centerX")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="flex-1"
+ icon="Y"
+ param={getNumberParamDefinition({
+ definition,
+ key: "centerY",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "centerY",
+ })}
+ onPreview={previewNumberParam("centerY")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "width-height" &&
+ "width" in mask.params &&
+ "height" in mask.params && (
+ <SectionField label="Size">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="W"
+ param={getNumberParamDefinition({
+ definition,
+ key: "width",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "width",
+ })}
+ onPreview={previewNumberParam("width")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="flex-1"
+ icon="H"
+ param={getNumberParamDefinition({
+ definition,
+ key: "height",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "height",
+ })}
+ onPreview={previewNumberParam("height")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "uniform" && "scale" in mask.params && (
+ <SectionField label="Scale">
+ <MaskNumberField
+ icon="S"
+ param={getNumberParamDefinition({
+ definition,
+ key: "scale",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "scale",
+ })}
+ onPreview={previewNumberParam("scale")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ {definition.features.hasRotation && "rotation" in mask.params && (
+ <SectionField label="Rotation">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ param={getNumberParamDefinition({
+ definition,
+ key: "rotation",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "rotation",
+ })}
+ onPreview={previewNumberParam("rotation")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ <SectionField label="Feather">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={FeatherIcon} />}
+ param={featherParam}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "feather",
+ })}
+ onPreview={previewNumberParam("feather")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+
+ <SectionField label="Stroke">
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="W"
+ param={strokeWidthParam}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "strokeWidth",
+ })}
+ onPreview={previewNumberParam("strokeWidth")}
+ onCommit={onCommit}
+ />
+ <ColorPicker
+ className=""
+ value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()}
+ onChange={(color) => previewStrokeColor(`#${color}`)}
+ onChangeEnd={(color) => {
+ previewStrokeColor(`#${color}`);
+ onCommit();
+ }}
+ />
+ </div>
+ {strokeAlignParam ? (
+ <Select
+ value={mask.params.strokeAlign}
+ onValueChange={(value) => {
+ previewParam("strokeAlign")(value);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {strokeAlignParam.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : null}
+ </div>
+ </SectionField>
+ </SectionFields>
+ );
+}
+
+function getNumberParamDefinition({
+ definition,
+ key,
+}: {
+ definition: RegisteredMaskDefinition;
+ key: string;
+}): NumberParamDefinition {
+ const param = definition.params.find((candidate) => candidate.key === key);
+
+ if (!param || param.type !== "number") {
+ throw new Error(`Missing number param definition for mask key "${key}"`);
+ }
+
+ return param;
+}
+
+function getMaskNumber({
+ params,
+ key,
+}: {
+ params: Mask["params"];
+ key: string;
+}): number {
+ const value = params[key];
+
+ if (typeof value !== "number") {
+ throw new Error(`Expected numeric mask param for "${key}"`);
+ }
+
+ return value;
+}
+
+function MaskNumberField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+ icon,
+ className,
+}: {
+ param: NumberParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+ icon?: React.ReactNode;
+ className?: string;
+}) {
+ const isPercent = param.unit === "percent";
+ const percentMax = param.max ?? 100;
+ const displayMultiplier = isPercent
+ ? 100 / percentMax
+ : (param.displayMultiplier ?? 1);
+ const min = isPercent ? 0 : param.min;
+ const max = isPercent ? 100 : param.max;
+ const step = isPercent ? 1 : param.step;
+ const displayValue = value * displayMultiplier;
+ const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
+
+ const clampDisplay = (nextDisplayValue: number) =>
+ max !== undefined
+ ? clamp({ value: nextDisplayValue, min, max })
+ : Math.max(min, nextDisplayValue);
+
+ const previewFromDisplay = (nextDisplayValue: number) => {
+ onPreview(
+ clampDisplay(snapToStep({ value: nextDisplayValue, step })) /
+ displayMultiplier,
+ );
+ };
+
+ const draft = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: displayValue,
+ maxFractionDigits,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
+ },
+ onPreview,
+ onCommit,
+ });
+
+ return (
+ <NumberField
+ className={className}
+ icon={icon}
+ value={draft.displayValue}
+ dragSensitivity="slow"
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ onScrub={previewFromDisplay}
+ onScrubEnd={onCommit}
+ />
+ );
+}
+
+function EmptyView({ onAddMask }: EmptyViewProps) {
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No masks</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-40">
+ Add a mask to hide or reveal parts of this layer.
+ </p>
+ </div>
+ <Button variant="default" size="sm" onClick={onAddMask}>
+ Add mask
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
new file mode 100644
index 00000000..44f5dd74
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
@@ -0,0 +1,137 @@
+import { useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
+import { buildConstantRetime } from "@/lib/retime";
+import {
+ DEFAULT_RETIME_RATE,
+ MIN_RETIME_RATE,
+ MAX_RETIME_RATE,
+ clampRetimeRate,
+ canMaintainPitch,
+} from "@/constants/retime-constants";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+
+function rateToDisplay({ rate }: { rate: number }): string {
+ return rate.toFixed(2);
+}
+
+function parseSpeedInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ const rounded = Math.round(parsed * 100) / 100;
+ return clampRetimeRate({ rate: rounded });
+}
+
+function buildRetime({
+ rate,
+ maintainPitch,
+}: {
+ rate: number;
+ maintainPitch: boolean;
+}) {
+ if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined;
+ return buildConstantRetime({ rate, maintainPitch });
+}
+
+export function SpeedTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const rate = clampRetimeRate({
+ rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
+ });
+ const maintainPitchAvailable = canMaintainPitch({ rate });
+ const maintainPitch = element.retime?.maintainPitch ?? false;
+ const pendingRateRef = useRef(rate);
+
+ const applyRetime = ({
+ rate: nextRate,
+ maintainPitch: nextMaintainPitch,
+ pushHistory = true,
+ }: {
+ rate: number;
+ maintainPitch: boolean;
+ pushHistory?: boolean;
+ }) => {
+ editor.timeline.updateElementRetime({
+ trackId,
+ elementId: element.id,
+ retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
+ pushHistory,
+ });
+ };
+
+ const speedDraft = usePropertyDraft({
+ displayValue: rateToDisplay({ rate }),
+ parse: (input) => parseSpeedInput({ input }),
+ onPreview: (nextRate) => {
+ pendingRateRef.current = nextRate;
+ applyRetime({ rate: nextRate, maintainPitch, pushHistory: false });
+ },
+ onCommit: () => {
+ applyRetime({ rate: pendingRateRef.current, maintainPitch });
+ },
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:speed`}>
+ <SectionHeader>
+ <SectionTitle>Speed</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Speed">
+ <NumberField
+ icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />}
+ value={speedDraft.displayValue}
+ suffix="x"
+ scrubRanges={[
+ { from: 0.01, to: 1, pixelsPerUnit: 160 },
+ { from: 1, to: 5, pixelsPerUnit: 48 },
+ ]}
+ scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }}
+ onFocus={() => {
+ pendingRateRef.current = rate;
+ speedDraft.onFocus();
+ }}
+ onChange={speedDraft.onChange}
+ onBlur={speedDraft.onBlur}
+ onScrub={speedDraft.scrubTo}
+ onScrubEnd={speedDraft.commitScrub}
+ onReset={() =>
+ applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
+ }
+ isDefault={rate === DEFAULT_RETIME_RATE}
+ />
+ </SectionField>
+ <div className="flex items-center justify-between">
+ <span className="text-sm">Change pitch</span>
+ <Switch
+ checked={!maintainPitch}
+ disabled={!maintainPitchAvailable}
+ onCheckedChange={(checked) =>
+ applyRetime({ rate, maintainPitch: !checked })
+ }
+ />
+ </div>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
index ae6de420..f8e2527a 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
@@ -1,6 +1,8 @@
+"use client";
+
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
-import type { TextElement } from "@/types/timeline";
+import type { TextElement } from "@/lib/timeline";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
import {
@@ -10,7 +12,7 @@ import {
SectionFields,
SectionHeader,
SectionTitle,
-} from "./section";
+} from "@/components/section";
import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
@@ -20,31 +22,27 @@ import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
- DEFAULT_LETTER_SPACING,
- DEFAULT_LINE_HEIGHT,
- DEFAULT_TEXT_BACKGROUND,
- DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
-import { usePropertyDraft } from "./hooks/use-property-draft";
-import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property";
-import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "./hooks/use-element-playhead";
-import { TransformSection, BlendingSection } from "./sections";
-import { KeyframeToggle } from "./keyframe-toggle";
-import { isPropertyAtDefault } from "./sections/transform";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { isPropertyAtDefault } from "./transform-tab";
import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import {
+ MinusSignIcon,
+ PlusSignIcon,
TextFontIcon,
- ViewIcon,
- ViewOffSlashIcon,
} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
+import { DEFAULTS } from "@/lib/timeline/defaults";
import { cn } from "@/utils/ui";
-export function TextProperties({
+export function TextTab({
element,
trackId,
}: {
@@ -52,10 +50,8 @@ export function TextProperties({
trackId: string;
}) {
return (
- <div className="flex h-full flex-col">
+ <div className="flex flex-col">
<ContentSection element={element} trackId={trackId} />
- <TransformSection element={element} trackId={trackId} />
- <BlendingSection element={element} trackId={trackId} />
<TypographySection element={element} trackId={trackId} />
<SpacingSection element={element} trackId={trackId} />
<BackgroundSection element={element} trackId={trackId} />
@@ -85,7 +81,7 @@ function ContentSection({
});
return (
- <Section collapsible sectionKey="text:content" showTopBorder={false}>
+ <Section collapsible sectionKey={`${element.id}:content`}>
<SectionHeader>
<SectionTitle>Content</SectionTitle>
</SectionHeader>
@@ -138,7 +134,11 @@ function TypographySection({
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
+ return clamp({
+ value: Math.round(parsed),
+ min: MIN_FONT_SIZE,
+ max: MAX_FONT_SIZE,
+ });
},
onPreview: (value) =>
editor.timeline.previewElements({
@@ -150,7 +150,7 @@ function TypographySection({
});
return (
- <Section collapsible sectionKey="text:typography">
+ <Section collapsible sectionKey={`${element.id}:typography`}>
<SectionHeader>
<SectionTitle>Typography</SectionTitle>
</SectionHeader>
@@ -188,12 +188,14 @@ function TypographySection({
{
trackId,
elementId: element.id,
- updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
+ updates: {
+ fontSize: DEFAULTS.text.element.fontSize,
+ },
},
],
})
}
- isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
icon={<HugeiconsIcon icon={TextFontIcon} />}
/>
</SectionField>
@@ -233,7 +235,7 @@ function SpacingSection({
const letterSpacing = usePropertyDraft({
displayValue: Math.round(
- element.letterSpacing ?? DEFAULT_LETTER_SPACING,
+ element.letterSpacing ?? DEFAULTS.text.letterSpacing,
).toString(),
parse: (input) => {
const parsed = parseFloat(input);
@@ -249,7 +251,7 @@ function SpacingSection({
});
const lineHeight = usePropertyDraft({
- displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
+ displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed)
@@ -266,7 +268,7 @@ function SpacingSection({
});
return (
- <Section collapsible sectionKey="text:spacing" showBottomBorder={false}>
+ <Section collapsible sectionKey={`${element.id}:spacing`}>
<SectionHeader>
<SectionTitle>Spacing</SectionTitle>
</SectionHeader>
@@ -286,14 +288,14 @@ function SpacingSection({
{
trackId,
elementId: element.id,
- updates: { letterSpacing: DEFAULT_LETTER_SPACING },
+ updates: { letterSpacing: DEFAULTS.text.letterSpacing },
},
],
})
}
isDefault={
- (element.letterSpacing ?? DEFAULT_LETTER_SPACING) ===
- DEFAULT_LETTER_SPACING
+ (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
+ DEFAULTS.text.letterSpacing
}
icon={<OcTextWidthIcon size={14} />}
/>
@@ -312,14 +314,14 @@ function SpacingSection({
{
trackId,
elementId: element.id,
- updates: { lineHeight: DEFAULT_LINE_HEIGHT },
+ updates: { lineHeight: DEFAULTS.text.lineHeight },
},
],
})
}
isDefault={
- (element.lineHeight ?? DEFAULT_LINE_HEIGHT) ===
- DEFAULT_LINE_HEIGHT
+ (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
+ DEFAULTS.text.lineHeight
}
icon={<OcTextHeightIcon size={14} />}
/>
@@ -366,25 +368,25 @@ function BackgroundSection({
const bg = element.background;
const resolvedPaddingX = resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
animations: element.animations,
propertyPath: "background.paddingX",
localTime,
});
const resolvedPaddingY = resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
animations: element.animations,
propertyPath: "background.paddingY",
localTime,
});
const resolvedOffsetX = resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
animations: element.animations,
propertyPath: "background.offsetX",
localTime,
});
const resolvedOffsetY = resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
animations: element.animations,
propertyPath: "background.offsetY",
localTime,
@@ -409,6 +411,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingX,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingX: value },
}),
@@ -427,6 +430,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingY,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingY: value },
}),
@@ -445,6 +449,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
valueAtPlayhead: resolvedOffsetX,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetX: value },
}),
@@ -463,6 +468,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
valueAtPlayhead: resolvedOffsetY,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetY: value },
}),
@@ -479,9 +485,14 @@ function BackgroundSection({
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
- return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX });
+ return clamp({
+ value: Math.round(parsed),
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ });
},
valueAtPlayhead: resolvedCornerRadius,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, cornerRadius: value },
}),
@@ -514,7 +525,7 @@ function BackgroundSection({
<Section
collapsible
defaultOpen={element.background.enabled}
- sectionKey="text:background"
+ sectionKey={`${element.id}:background`}
>
<SectionHeader
trailing={
@@ -527,7 +538,8 @@ function BackgroundSection({
}}
>
<HugeiconsIcon
- icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon}
+ icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
/>
</Button>
}
@@ -590,13 +602,17 @@ function BackgroundSection({
onBlur={paddingX.onBlur}
onScrub={paddingX.scrubTo}
onScrubEnd={paddingX.commitScrub}
- onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
+ onReset={() =>
+ paddingX.commitValue({
+ value: DEFAULTS.text.background.paddingX,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedPaddingX,
- staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
- defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
+ staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ defaultValue: DEFAULTS.text.background.paddingX,
})}
/>
</SectionField>
@@ -621,13 +637,17 @@ function BackgroundSection({
onBlur={paddingY.onBlur}
onScrub={paddingY.scrubTo}
onScrubEnd={paddingY.commitScrub}
- onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })}
+ onReset={() =>
+ paddingY.commitValue({
+ value: DEFAULTS.text.background.paddingY,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedPaddingY,
- staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
- defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY,
+ staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ defaultValue: DEFAULTS.text.background.paddingY,
})}
/>
</SectionField>
@@ -653,13 +673,17 @@ function BackgroundSection({
onBlur={offsetX.onBlur}
onScrub={offsetX.scrubTo}
onScrubEnd={offsetX.commitScrub}
- onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })}
+ onReset={() =>
+ offsetX.commitValue({
+ value: DEFAULTS.text.background.offsetX,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOffsetX,
- staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
- defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX,
+ staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ defaultValue: DEFAULTS.text.background.offsetX,
})}
/>
</SectionField>
@@ -683,13 +707,17 @@ function BackgroundSection({
onBlur={offsetY.onBlur}
onScrub={offsetY.scrubTo}
onScrubEnd={offsetY.commitScrub}
- onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })}
+ onReset={() =>
+ offsetY.commitValue({
+ value: DEFAULTS.text.background.offsetY,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOffsetY,
- staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
- defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY,
+ staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ defaultValue: DEFAULTS.text.background.offsetY,
})}
/>
</SectionField>
@@ -715,7 +743,9 @@ function BackgroundSection({
onBlur={cornerRadius.onBlur}
onScrub={cornerRadius.scrubTo}
onScrubEnd={cornerRadius.commitScrub}
- onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })}
+ onReset={() =>
+ cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
new file mode 100644
index 00000000..a015a9bb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
@@ -0,0 +1,424 @@
+import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { VisualElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Link05Icon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
+import { usePropertiesStore } from "../stores/properties-store";
+
+export function parseNumericInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function isPropertyAtDefault({
+ hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ staticValue,
+ defaultValue,
+}: {
+ hasAnimatedKeyframes: boolean;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number;
+ staticValue: number;
+ defaultValue: number;
+}): boolean {
+ if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
+ return isNearlyEqual({
+ leftValue: resolvedValue,
+ rightValue: defaultValue,
+ });
+ }
+
+ return staticValue === defaultValue;
+}
+
+export function TransformTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
+ const setTransformScaleLocked = usePropertiesStore(
+ (s) => s.setTransformScaleLocked,
+ );
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const position = useKeyframedVectorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position,
+ displayX: Math.round(resolvedTransform.position.x).toString(),
+ displayY: Math.round(resolvedTransform.position.y).toString(),
+ parseComponent: (input) => parseNumericInput({ input }),
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: { ...element.transform, position: value },
+ }),
+ });
+
+ const parseScale = (input: string) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return parsed / 100;
+ };
+
+ const scaleX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleX,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleX: value,
+ ...(isScaleLocked ? { scaleY: value } : {}),
+ },
+ }),
+ });
+
+ const scaleY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleY,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleY: value,
+ ...(isScaleLocked ? { scaleX: value } : {}),
+ },
+ }),
+ });
+
+ const scaleFieldPropsX = {
+ value: scaleX.displayValue,
+ onFocus: scaleX.onFocus,
+ onChange: scaleX.onChange,
+ onBlur: scaleX.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleX.scrubTo,
+ onScrubEnd: scaleX.commitScrub,
+ onReset: () =>
+ scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleX,
+ staticValue: element.transform.scaleX,
+ defaultValue: DEFAULTS.element.transform.scaleX,
+ }),
+ };
+
+ const scaleFieldPropsY = {
+ value: scaleY.displayValue,
+ onFocus: scaleY.onFocus,
+ onChange: scaleY.onChange,
+ onBlur: scaleY.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleY.scrubTo,
+ onScrubEnd: scaleY.commitScrub,
+ onReset: () =>
+ scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleY,
+ staticValue: element.transform.scaleY,
+ defaultValue: DEFAULTS.element.transform.scaleY,
+ }),
+ };
+
+ const rotation = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.rotate",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.rotate).toString(),
+ parse: (input) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ valueAtPlayhead: resolvedTransform.rotate,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
+ });
+
+ const hasScaleKeyframe = hasGroupKeyframeAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+
+ const toggleScaleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+ const existing = getGroupKeyframesAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+ if (existing.length > 0) {
+ editor.timeline.removeKeyframes({
+ keyframes: existing.map((ref) => ({
+ trackId,
+ elementId: element.id,
+ ...ref,
+ })),
+ });
+ return;
+ }
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleX",
+ time: localTime,
+ value: resolvedTransform.scaleX,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleY",
+ time: localTime,
+ value: resolvedTransform.scaleY,
+ },
+ ],
+ });
+ };
+
+ const scaleLockButton = (
+ <Button
+ type="button"
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setTransformScaleLocked(!isScaleLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ );
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:transform`}>
+ <SectionHeader>
+ <SectionTitle>Transform</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <div className="flex items-end gap-2">
+ {isScaleLocked ? (
+ <>
+ <SectionField
+ label="Scale"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={hasScaleKeyframe}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle scale keyframe"
+ onToggle={toggleScaleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldPropsX}
+ />
+ </SectionField>
+ {scaleLockButton}
+ </>
+ ) : (
+ <>
+ <SectionField
+ label="Width"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle width scale keyframe"
+ onToggle={scaleX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="W" {...scaleFieldPropsX} />
+ </SectionField>
+ {scaleLockButton}
+ <SectionField
+ label="Height"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle height scale keyframe"
+ onToggle={scaleY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="H" {...scaleFieldPropsY} />
+ </SectionField>
+ </>
+ )}
+ </div>
+ <SectionField
+ label="Position"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={position.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle position keyframe"
+ onToggle={position.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon="X"
+ className="flex-1"
+ value={position.x.displayValue}
+ onFocus={position.x.onFocus}
+ onChange={position.x.onChange}
+ onBlur={position.x.onBlur}
+ onScrub={position.x.scrubTo}
+ onScrubEnd={position.x.commitScrub}
+ onReset={() =>
+ position.commitX({
+ value: DEFAULTS.element.transform.position.x,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULTS.element.transform.position.x,
+ })}
+ />
+ <NumberField
+ icon="Y"
+ className="flex-1"
+ value={position.y.displayValue}
+ onFocus={position.y.onFocus}
+ onChange={position.y.onChange}
+ onBlur={position.y.onBlur}
+ onScrub={position.y.scrubTo}
+ onScrubEnd={position.y.commitScrub}
+ onReset={() =>
+ position.commitY({
+ value: DEFAULTS.element.transform.position.y,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULTS.element.transform.position.y,
+ })}
+ />
+ </div>
+ </SectionField>
+
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ onToggle={rotation.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ className="flex-none"
+ value={rotation.displayValue}
+ onFocus={rotation.onFocus}
+ onChange={rotation.onChange}
+ onBlur={rotation.onBlur}
+ dragSensitivity="slow"
+ onScrub={rotation.scrubTo}
+ onScrubEnd={rotation.commitScrub}
+ onReset={() =>
+ rotation.commitValue({
+ value: DEFAULTS.element.transform.rotate,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.rotate,
+ staticValue: element.transform.rotate,
+ defaultValue: DEFAULTS.element.transform.rotate,
+ })}
+ />
+ </div>
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx
deleted file mode 100644
index 49dd8fe5..00000000
--- a/apps/web/src/components/editor/panels/properties/video-properties.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import type {
- ImageElement,
- StickerElement,
- VideoElement,
-} from "@/types/timeline";
-import { BlendingSection, TransformSection } from "./sections";
-
-export function VideoProperties({
- element,
- trackId,
-}: {
- element: VideoElement | ImageElement | StickerElement;
- trackId: string;
-}) {
- return (
- <div className="flex h-full flex-col">
- <TransformSection
- element={element}
- trackId={trackId}
- showTopBorder={false}
- />
- <BlendingSection element={element} trackId={trackId} />
- </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
index bbc5cb83..cbb19bd0 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -1,176 +1,167 @@
-import { useEffect, useRef, useState } from "react";
-import WaveSurfer from "wavesurfer.js";
+"use client";
+import { useCallback, useEffect, useRef } from "react";
+import { useResizeObserver } from "@/hooks/use-resize-observer";
+import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
+import { findScrollParent } from "@/utils/browser";
+import { cn } from "@/utils/ui";
+
+const BAR_WIDTH = 2;
+const BAR_GAP = 1;
+const BAR_STEP = BAR_WIDTH + BAR_GAP;
interface AudioWaveformProps {
audioUrl?: string;
audioBuffer?: AudioBuffer;
- height?: number;
+ color?: string;
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,
+ color = "rgba(255, 255, 255, 0.7)",
className = "",
}: AudioWaveformProps) {
- const waveformRef = useRef<HTMLDivElement>(null);
- const wavesurfer = useRef<WaveSurfer | null>(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(false);
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const bufferRef = useRef<AudioBuffer | null>(null);
+ const globalMaxRef = useRef<number>(1);
+ const scrollParentRef = useRef<HTMLElement | null>(null);
+ const heightRef = useRef<number>(0);
- useEffect(() => {
- let mounted = true;
- const ws = wavesurfer.current;
+ const drawVisible = useCallback(() => {
+ const container = containerRef.current;
+ const canvas = canvasRef.current;
+ const buffer = bufferRef.current;
+ const height = heightRef.current;
- const initWaveSurfer = async () => {
- if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
+ if (!container || !canvas || !buffer || height <= 0) return;
- try {
- if (ws) {
- wavesurfer.current = null;
- }
+ const elementWidth = container.offsetWidth;
+ if (elementWidth <= 0) return;
- 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,
- });
+ const containerRect = container.getBoundingClientRect();
+ const scrollParent = scrollParentRef.current;
- if (mounted) {
- wavesurfer.current = newWaveSurfer;
- } else {
- try {
- newWaveSurfer.destroy();
- } catch {}
- return;
- }
+ let clipLeft: number;
+ let clipRight: number;
- newWaveSurfer.on("ready", () => {
- if (mounted) {
- setIsLoading(false);
- setError(false);
- }
- });
+ if (scrollParent) {
+ const parentRect = scrollParent.getBoundingClientRect();
+ clipLeft = Math.max(0, parentRect.left - containerRect.left);
+ clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
+ } else {
+ clipLeft = Math.max(0, -containerRect.left);
+ clipRight = Math.min(
+ elementWidth,
+ window.innerWidth - containerRect.left,
+ );
+ }
- newWaveSurfer.on("error", (err) => {
- if (mounted) {
- console.error("WaveSurfer error:", err);
- setError(true);
- setIsLoading(false);
- }
- });
+ const visibleWidth = clipRight - clipLeft;
+ if (visibleWidth <= 0) return;
- 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);
- }
- }
- };
+ const dpr = window.devicePixelRatio || 1;
+ const canvasW = Math.round(visibleWidth * dpr);
+ const canvasH = Math.round(height * dpr);
+
+ if (canvasW <= 0 || canvasH <= 0) return;
+
+ canvas.width = canvasW;
+ canvas.height = canvasH;
+ canvas.style.width = `${visibleWidth}px`;
+ canvas.style.height = `${height}px`;
+ canvas.style.left = `${clipLeft}px`;
+
+ const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
+ const startFraction = clipLeft / elementWidth;
+ const endFraction = clipRight / elementWidth;
+ const startSample = Math.floor(startFraction * buffer.length);
+ const endSample = Math.min(
+ buffer.length,
+ Math.ceil(endFraction * buffer.length),
+ );
- if (ws) {
- const wsToDestroy = ws;
- wavesurfer.current = null;
+ const peaks = extractRmsRange({
+ buffer,
+ count: barCount,
+ startSample,
+ endSample,
+ globalMax: globalMaxRef.current,
+ });
- requestAnimationFrame(() => {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ ctx.clearRect(0, 0, canvasW, canvasH);
+ ctx.scale(dpr, dpr);
+ ctx.fillStyle = color;
+
+ const maxBarHeight = height * 0.7;
+
+ for (let i = 0; i < barCount; i++) {
+ const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
+ const barH = Math.max(1, scaled * maxBarHeight);
+ ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
+ }
+ }, [color]);
+
+ useEffect(() => {
+ let isCancelled = false;
+
+ async function load() {
+ let buffer = audioBuffer ?? null;
+
+ if (!buffer && audioUrl) {
try {
- wsToDestroy.destroy();
- } catch {}
- if (mounted) {
- initWaveSurfer();
+ const resp = await fetch(audioUrl);
+ const arrayBuffer = await resp.arrayBuffer();
+ const actx = new AudioContext();
+ buffer = await actx.decodeAudioData(arrayBuffer);
+ actx.close();
+ } catch {
+ return;
}
- });
- } else {
- initWaveSurfer();
+ }
+
+ if (!buffer || isCancelled) return;
+
+ bufferRef.current = buffer;
+ globalMaxRef.current = computeGlobalMaxRms({ buffer });
+ drawVisible();
}
+ load();
return () => {
- mounted = false;
+ isCancelled = true;
+ };
+ }, [audioUrl, audioBuffer, drawVisible]);
- const wsToDestroy = wavesurfer.current;
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
- wavesurfer.current = null;
+ scrollParentRef.current = findScrollParent({ element: container });
+ const scrollParent = scrollParentRef.current;
+ if (!scrollParent) return;
- if (wsToDestroy) {
- requestAnimationFrame(() => {
- try {
- wsToDestroy.destroy();
- } catch {}
- });
- }
- };
- }, [audioUrl, audioBuffer, height]);
+ scrollParent.addEventListener("scroll", drawVisible, { passive: true });
+ return () => scrollParent.removeEventListener("scroll", drawVisible);
+ }, [drawVisible]);
- if (error) {
- return (
- <div
- className={`flex items-center justify-center ${className}`}
- style={{ height }}
- >
- <span className="text-foreground/60 text-xs">Audio unavailable</span>
- </div>
- );
- }
+ const onResize = useCallback(
+ (entry: ResizeObserverEntry) => {
+ heightRef.current = entry.contentRect.height;
+ drawVisible();
+ },
+ [drawVisible],
+ );
+
+ useResizeObserver({ ref: containerRef, onResize });
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 ref={containerRef} className={cn("relative size-full", className)}>
+ <canvas ref={canvasRef} className="absolute bottom-0" />
</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
index 864bb557..7000f98b 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -5,7 +5,10 @@ import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
-import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants";
+import {
+ DEFAULT_BOOKMARK_COLOR,
+ TIMELINE_BOOKMARK_ROW_HEIGHT,
+} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import {
@@ -13,7 +16,7 @@ import {
Delete02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import type { Bookmark } from "@/types/timeline";
+import type { Bookmark } from "@/lib/timeline";
import {
Popover,
PopoverAnchor,
@@ -25,7 +28,10 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
-import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline";
+import {
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
@@ -72,14 +78,17 @@ export function TimelineBookmarksRow({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineBookmarksRowProps) {
- const editor = useEditor();
- const activeScene = editor.scenes.getActiveScene();
+ const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks);
return (
- <div className="relative h-4 flex-1 overflow-hidden">
+ <div
+ className="relative flex-1 overflow-hidden"
+ style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }}
+ >
<button
- className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
+ className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
style={{
+ height: TIMELINE_BOOKMARK_ROW_HEIGHT,
width: `${dynamicTimelineWidth}px`,
}}
aria-label="Timeline ruler"
@@ -95,7 +104,7 @@ export function TimelineBookmarksRow({
handleRulerTrackingMouseDown(event);
}}
>
- {activeScene.bookmarks.map((bookmark) => (
+ {bookmarks.map((bookmark) => (
<TimelineBookmark
key={`bookmark-${bookmark.time}`}
bookmark={bookmark}
@@ -174,7 +183,7 @@ function TimelineBookmark({
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverAnchor asChild>
<button
- className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
+ className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0"
style={{
left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`,
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
index 291a4633..7597a620 100644
--- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx
+++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
@@ -1,5 +1,6 @@
import { getDropLineY } from "@/lib/timeline/drop-utils";
-import type { TimelineTrack, DropTarget } from "@/types/timeline";
+import type { TimelineTrack, DropTarget } from "@/lib/timeline";
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
interface DragLineProps {
dropTarget: DropTarget | null;
@@ -22,7 +23,7 @@ export function DragLine({
return (
<div
className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
- style={{ top: `${lineTop}px` }}
+ style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
/>
);
}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 593976a8..f1ecd9f4 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -3,31 +3,44 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Delete02Icon,
+ MagicWand05Icon,
+ MusicNote03Icon,
TaskAdd02Icon,
+ TextIcon,
ViewIcon,
ViewOffSlashIcon,
VolumeHighIcon,
VolumeOffIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
-} from "../../../ui/context-menu";
+} from "@/components/ui/context-menu";
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
-import { useState, useRef, useCallback } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import type { ElementDragState, DropTarget } from "@/lib/timeline";
import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead";
-import { SelectionBox } from "../../selection-box";
-import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
+import { SelectionBox } from "@/lib/selection/selection-box";
+import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import {
TIMELINE_CONSTANTS,
- TRACK_CONFIG,
+ TRACK_GAP,
+ TIMELINE_SCROLLBAR_SIZE_PX,
} from "@/constants/timeline-constants";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
@@ -36,12 +49,11 @@ import {
getTotalTracksHeight,
canTracktHaveAudio,
canTrackBeHidden,
+ isMainTrack,
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";
@@ -49,27 +61,58 @@ import { TimelineRuler } from "./timeline-ruler";
import { TimelineBookmarksRow } from "./bookmarks";
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
+import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing";
+import { cn } from "@/utils/ui";
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
const FALLBACK_CONTAINER_WIDTH = 1000;
+const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
+const TRACK_ICONS: Record<TimelineTrack["type"], ReactNode> = {
+ video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
+ text: (
+ <HugeiconsIcon
+ icon={TextIcon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+ audio: (
+ <HugeiconsIcon
+ icon={MusicNote03Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+ graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />,
+ effect: (
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+};
export function Timeline() {
- const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
- const { clearElementSelection, setElementSelection } = useElementSelection();
+ const {
+ selectedElements,
+ clearElementSelection,
+ setElementSelection,
+ mergeElementsIntoSelection,
+ } = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
- const tracks = timeline.getTracks();
+ const tracks = useEditor((editor) => editor.timeline.getTracks());
const seek = (time: number) => editor.playback.seek({ time });
const timelineRef = useRef<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
+ const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksContainerRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
@@ -110,8 +153,109 @@ export function Timeline() {
initialScrollLeft: savedViewState?.scrollLeft,
initialPlayheadTime: savedViewState?.playheadTime,
tracksScrollRef,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
+ });
+
+ // Stable refs so the wheel listener never goes stale
+ const setZoomLevelRef = useRef(setZoomLevel);
+ useEffect(() => {
+ setZoomLevelRef.current = setZoomLevel;
+ }, [setZoomLevel]);
+
+ const saveScrollPositionRef = useRef(saveScrollPosition);
+ useEffect(() => {
+ saveScrollPositionRef.current = saveScrollPosition;
+ }, [saveScrollPosition]);
+
+ const minZoomLevelRef = useRef(minZoomLevel);
+ useEffect(() => {
+ minZoomLevelRef.current = minZoomLevel;
+ }, [minZoomLevel]);
+
+ // Pushes tracks scroll position to the two overflow:hidden followers
+ // (ruler and track labels). Called from the wheel handler (before paint,
+ // zero lag) and from onScroll on the tracks area (covers scrollbar drag).
+ const syncFollowers = useCallback(() => {
+ const tracks = tracksScrollRef.current;
+ if (!tracks) return;
+ if (rulerScrollRef.current) {
+ rulerScrollRef.current.scrollLeft = tracks.scrollLeft;
+ }
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = tracks.scrollTop;
+ }
+ }, []);
+
+ // Single non-passive capture listener owns all wheel input. Prevents any
+ // native scroll or browser zoom from firing inside the timeline.
+ useEffect(() => {
+ const container = timelineRef.current;
+ if (!container) return;
+
+ let pendingZoomDelta = 0;
+ let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
+
+ const onWheel = (e: WheelEvent) => {
+ const isZoom = e.ctrlKey || e.metaKey;
+
+ if (isZoom) {
+ e.preventDefault();
+ const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
+ pendingZoomDelta += normalizedDelta;
+
+ if (zoomRafId === null) {
+ zoomRafId = requestAnimationFrame(() => {
+ const frameRawDelta = pendingZoomDelta;
+ const cappedDelta =
+ Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ setZoomLevelRef.current((prev) => prev * zoomFactor);
+ pendingZoomDelta = 0;
+ zoomRafId = null;
+ });
+ }
+ return;
+ }
+
+ const tracks = tracksScrollRef.current;
+ if (!tracks) return;
+
+ const isHorizontal =
+ e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY);
+
+ e.preventDefault();
+
+ if (isHorizontal) {
+ const raw =
+ Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
+ const clamped =
+ Math.sign(raw) *
+ Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX);
+ tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
+ } else {
+ tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
+ }
+
+ syncFollowers();
+ saveScrollPositionRef.current();
+ };
+
+ container.addEventListener("wheel", onWheel, {
+ passive: false,
+ capture: true,
});
+ return () => {
+ container.removeEventListener("wheel", onWheel, { capture: true });
+ if (zoomRafId !== null) cancelAnimationFrame(zoomRafId);
+ };
+ }, [syncFollowers]);
+
+ useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll: () => saveScrollPositionRef.current(),
+ isReady: tracks.length > 0,
+ });
const {
dragState,
@@ -124,7 +268,6 @@ export function Timeline() {
timelineRef,
tracksContainerRef,
tracksScrollRef,
- headerRef: timelineHeaderRef,
snappingEnabled,
onSnapPointChange: handleSnapPointChange,
});
@@ -144,14 +287,13 @@ export function Timeline() {
useTimelinePlayhead({
zoomLevel,
rulerRef,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
tracksScrollRef,
playheadRef,
});
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
- headerRef: timelineHeaderRef,
tracksScrollRef,
zoomLevel,
});
@@ -161,17 +303,37 @@ export function Timeline() {
handleMouseDown: handleSelectionMouseDown,
isSelecting,
shouldIgnoreClick,
- } = useSelectionBox({
+ } = useBoxSelect({
containerRef: tracksContainerRef,
- headerRef: timelineHeaderRef,
- onSelectionComplete: (elements) => {
- setElementSelection({ elements });
+ selectedIds: selectedElements,
+ anchorId: null,
+ getIsAdditiveSelection: (event) =>
+ event.shiftKey || event.ctrlKey || event.metaKey,
+ resolveIntersections: ({ startPos, currentPos }) => {
+ if (!tracksContainerRef.current) {
+ return [];
+ }
+
+ return resolveTimelineElementIntersections({
+ container: tracksContainerRef.current,
+ scrollContainer: tracksScrollRef.current,
+ tracks,
+ zoomLevel,
+ startPos,
+ currentPos,
+ });
+ },
+ onSelectionChange: ({ intersectedIds, isAdditive }) => {
+ if (isAdditive) {
+ mergeElementsIntoSelection({ elements: intersectedIds });
+ } else {
+ setElementSelection({ elements: intersectedIds });
+ }
},
- tracksScrollRef,
- zoomLevel,
});
- const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
+ const containerWidth =
+ tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({
@@ -183,11 +345,16 @@ export function Timeline() {
contentWidth + paddingPx,
containerWidth,
);
+ const tracksViewportWidth =
+ tracksScrollRef.current?.clientWidth ??
+ tracksContainerRef.current?.clientWidth ??
+ containerWidth;
+ const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth;
useEdgeAutoScroll({
isActive: bookmarkDragState.isDragging,
getMouseClientX: () => bookmarkLastMouseXRef.current,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
tracksScrollRef,
contentWidth: dynamicTimelineWidth,
});
@@ -205,7 +372,7 @@ export function Timeline() {
} = useTimelineSeek({
playheadRef,
trackLabelsRef,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
tracksScrollRef,
zoomLevel,
duration: timeline.getTotalDuration(),
@@ -214,13 +381,9 @@ export function Timeline() {
seek,
});
- useScrollSync({
- tracksScrollRef,
- trackLabelsScrollRef,
- });
-
const timelineHeaderHeight =
- timelineHeaderRef.current?.getBoundingClientRect().height ?? 0;
+ (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
+ TIMELINE_CONSTANTS.PADDING_TOP_PX || 0;
return (
<section
@@ -236,315 +399,409 @@ export function Timeline() {
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}
+ <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}>
+ <TrackLabelsPanel
trackLabelsRef={trackLabelsRef}
- tracksScrollRef={tracksScrollRef}
- isVisible={showSnapIndicator}
+ trackLabelsScrollRef={trackLabelsScrollRef}
+ timelineHeaderHeight={timelineHeaderHeight}
+ hasHorizontalScrollbar={hasHorizontalScrollbar}
/>
- <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
+ className="relative isolate flex flex-1 flex-col overflow-hidden"
+ ref={tracksContainerRef}
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <DragLine
+ dropTarget={dropTarget}
+ tracks={timeline.getTracks()}
+ isVisible={isDragOver && !dropTarget?.targetElement}
+ headerHeight={timelineHeaderHeight}
+ />
+ <DragLine
+ dropTarget={dragDropTarget}
+ tracks={timeline.getTracks()}
+ isVisible={dragState.isDragging}
+ headerHeight={timelineHeaderHeight}
+ />
+
+ <div ref={rulerScrollRef} className="shrink-0 overflow-hidden">
+ <div
+ ref={timelineHeaderRef}
+ className="flex flex-col"
+ style={{ width: `${dynamicTimelineWidth}px` }}
+ >
+ <TimelineRuler
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ rulerRef={rulerRef}
+ tracksScrollRef={rulerScrollRef}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
+ <TimelineBookmarksRow
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ dragState={bookmarkDragState}
+ onBookmarkMouseDown={handleBookmarkMouseDown}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
</div>
- {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}
+ <ScrollArea
+ className="flex-1"
+ ref={tracksScrollRef}
+ onScroll={() => {
+ syncFollowers();
+ saveScrollPosition();
+ }}
>
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
- <DragLine
- dropTarget={dropTarget}
- tracks={timeline.getTracks()}
- isVisible={isDragOver && !dropTarget?.targetElement}
- 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="flex min-h-full flex-col"
+ style={{ width: `${dynamicTimelineWidth}px` }}
>
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
<div
- className="relative"
+ className="relative shrink-0"
style={{
- width: `${dynamicTimelineWidth}px`,
+ height: `${
+ Math.max(
+ TRACKS_CONTAINER_HEIGHT.min,
+ Math.min(
+ TRACKS_CONTAINER_HEIGHT.max,
+ getTotalTracksHeight({ tracks }),
+ ),
+ ) + TIMELINE_CONSTANTS.PADDING_TOP_PX
+ }px`,
+ }}
+ onMouseDown={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksClick(event);
}}
>
- <div
- ref={timelineHeaderRef}
- className="bg-background sticky top-0 flex flex-col"
- >
- <TimelineRuler
+ {tracks.length > 0 && (
+ <TimelineTrackRows
+ dragElementId={dragState.elementId}
zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- rulerRef={rulerRef}
+ dragState={dragState}
tracksScrollRef={tracksScrollRef}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
- />
- <TimelineBookmarksRow
- zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- dragState={bookmarkDragState}
- onBookmarkMouseDown={handleBookmarkMouseDown}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ lastMouseXRef={lastMouseXRef}
+ onSnapPointChange={handleSnapPointChange}
+ onResizeStateChange={handleResizeStateChange}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ onTrackMouseDown={(event) => {
+ handleSelectionMouseDown(event);
+ handleTracksMouseDown(event);
+ }}
+ onTrackMouseUp={handleTracksClick}
+ shouldIgnoreClick={shouldIgnoreClick}
+ isDragOver={isDragOver}
+ dropTarget={dropTarget}
/>
- </div>
- <TimelinePlayhead
- zoomLevel={zoomLevel}
- rulerRef={rulerRef}
- rulerScrollRef={tracksScrollRef}
- tracksScrollRef={tracksScrollRef}
- timelineRef={timelineRef}
- playheadRef={playheadRef}
- isSnappingToPlayhead={
- showSnapIndicator && currentSnapPoint?.type === "playhead"
- }
- />
+ )}
+ </div>
+ <TimelineGutter
+ onMouseDown={(event) => {
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={handleTracksClick}
+ />
+ </div>
+ </ScrollArea>
+
+ <TimelinePlayhead
+ zoomLevel={zoomLevel}
+ hasHorizontalScrollbar={hasHorizontalScrollbar}
+ rulerRef={rulerRef}
+ rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ </div>
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ timelineRef={timelineRef}
+ tracksScrollRef={tracksScrollRef}
+ isVisible={showSnapIndicator}
+ tracks={tracks}
+ />
+ </div>
+ </section>
+ );
+}
+
+function TrackLabelsPanel({
+ trackLabelsRef,
+ trackLabelsScrollRef,
+ timelineHeaderHeight,
+ hasHorizontalScrollbar,
+}: {
+ trackLabelsRef: React.RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
+ timelineHeaderHeight: number;
+ hasHorizontalScrollbar: boolean;
+}) {
+ const editor = useEditor();
+ const tracks = useEditor((e) => e.timeline.getTracks());
+ const { selectedElements } = useElementSelection();
+ const tracksWithSelection = useMemo(
+ () => new Set(selectedElements.map((el) => el.trackId)),
+ [selectedElements],
+ );
+
+ return (
+ <div className="flex w-28 shrink-0 flex-col border-r">
+ <div
+ className="shrink-0"
+ style={{ height: timelineHeaderHeight || 48 }}
+ />
+ <div ref={trackLabelsRef} className="flex-1 overflow-hidden">
+ <div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
+ {tracks.length > 0 && (
+ <div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}>
+ {tracks.map((track) => (
<div
- className="relative"
+ key={track.id}
+ className={cn(
+ "group flex items-center px-3",
+ tracksWithSelection.has(track.id) &&
+ TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
+ )}
style={{
- height: `${Math.max(
- tracksContainerHeight.min,
- Math.min(
- tracksContainerHeight.max,
- getTotalTracksHeight({ tracks }),
- ),
- )}px`,
+ height: `${getTrackHeight({ type: track.type })}px`,
}}
>
- {tracks.length === 0 ? (
- <div />
- ) : (
- [...tracks]
- .map((track, index) => ({ track, index }))
- .sort((a, b) => {
- const aHasDragged = a.track.elements.some(
- (element) => element.id === dragState.elementId,
- );
- const bHasDragged = b.track.elements.some(
- (element) => element.id === dragState.elementId,
- );
- if (aHasDragged) return 1;
- if (bHasDragged) return -1;
- return 0;
- })
- .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}
- targetElementId={
- isDragOver
- ? dropTarget?.targetElement?.elementId ?? null
- : null
- }
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="w-40">
- <ContextMenuItem
- icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
- onClick={(event) => {
- event.stopPropagation();
- invokeAction("paste-copied");
- }}
- >
- Paste elements
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(event) => {
- event.stopPropagation();
- timeline.toggleTrackMute({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={VolumeHighIcon} />
- <span>
- {canTracktHaveAudio(track) && track.muted
- ? "Unmute track"
- : "Mute track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(event) => {
- event.stopPropagation();
- timeline.toggleTrackVisibility({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={ViewIcon} />
- <span>
- {canTrackBeHidden(track) && track.hidden
- ? "Show track"
- : "Hide track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(event) => {
- event.stopPropagation();
- timeline.removeTrack({
- trackId: track.id,
- });
- }}
- variant="destructive"
- >
- <HugeiconsIcon icon={Delete02Icon} />
- Delete track
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))
- )}
+ <div className="flex min-w-0 flex-1 items-center justify-end gap-2">
+ {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>
</div>
- </section>
+ <div
+ className="bg-background shrink-0"
+ style={{
+ height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0,
+ }}
+ />
+ </div>
+ );
+}
+
+function TimelineTrackRows({
+ dragElementId,
+ zoomLevel,
+ dragState,
+ tracksScrollRef,
+ lastMouseXRef,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ onTrackMouseUp,
+ shouldIgnoreClick,
+ isDragOver,
+ dropTarget,
+}: {
+ dragElementId: string | null;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ lastMouseXRef: React.RefObject<number>;
+ onSnapPointChange: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onElementMouseDown"];
+ onElementClick: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onElementClick"];
+ onTrackMouseDown: (event: React.MouseEvent) => void;
+ onTrackMouseUp: (event: React.MouseEvent) => void;
+ shouldIgnoreClick: () => boolean;
+ isDragOver: boolean;
+ dropTarget: DropTarget | null;
+}) {
+ const timeline = useEditor((e) => e.timeline);
+ const tracks = useEditor((e) => e.timeline.getTracks());
+ const { selectedElements } = useElementSelection();
+ const tracksWithSelection = useMemo(
+ () => new Set(selectedElements.map((el) => el.trackId)),
+ [selectedElements],
+ );
+
+ const sortedTracks = useMemo(
+ () =>
+ [...tracks]
+ .map((track, index) => ({ track, index }))
+ .sort((a, b) => {
+ const aHasDragged = a.track.elements.some(
+ (el) => el.id === dragElementId,
+ );
+ const bHasDragged = b.track.elements.some(
+ (el) => el.id === dragElementId,
+ );
+ if (aHasDragged) return 1;
+ if (bHasDragged) return -1;
+ return 0;
+ }),
+ [tracks, dragElementId],
);
+
+ return (
+ <>
+ {sortedTracks.map(({ track, index }) => (
+ <ContextMenu key={track.id}>
+ <ContextMenuTrigger asChild>
+ <div
+ className={cn(
+ "absolute right-0 left-0 transition-colors",
+ tracksWithSelection.has(track.id) &&
+ TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
+ )}
+ style={{
+ top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + 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={onSnapPointChange}
+ onResizeStateChange={onResizeStateChange}
+ onElementMouseDown={onElementMouseDown}
+ onElementClick={onElementClick}
+ onTrackMouseDown={onTrackMouseDown}
+ onTrackMouseUp={onTrackMouseUp}
+ shouldIgnoreClick={shouldIgnoreClick}
+ targetElementId={
+ isDragOver
+ ? (dropTarget?.targetElement?.elementId ?? null)
+ : null
+ }
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-40">
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackMute({ trackId: track.id });
+ }}
+ >
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={ViewIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackVisibility({ trackId: track.id });
+ }}
+ >
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+ </ContextMenuItem>
+ {!isMainTrack(track) && (
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.removeTrack({ trackId: track.id });
+ }}
+ variant="destructive"
+ >
+ Delete track
+ </ContextMenuItem>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </>
+ );
+}
+
+function TimelineGutter({
+ onMouseDown,
+ onClick,
+}: {
+ onMouseDown: (event: React.MouseEvent) => void;
+ onClick: (event: React.MouseEvent) => void;
+}) {
+ // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system
+ // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system
+ return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />;
}
function TrackIcon({ track }: { track: TimelineTrack }) {
- return <>{TRACK_CONFIG[track.type].icon}</>;
+ return <>{TRACK_ICONS[track.type]}</>;
}
function TrackToggleIcon({
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
index 927d336a..acb57203 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -6,15 +6,12 @@ import {
getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
} from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
-
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
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>;
}
@@ -22,17 +19,13 @@ export function SnapIndicator({
snapPoint,
zoomLevel,
isVisible,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
}: SnapIndicatorProps) {
const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
snapPoint,
zoomLevel,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
});
@@ -48,6 +41,7 @@ export function SnapIndicator({
top: topPosition,
height: `${height}px`,
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.snapIndicator,
}}
>
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 9121f6c2..13a31375 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -2,7 +2,7 @@
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-import AudioWaveform from "./audio-waveform";
+import { AudioWaveform } from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import {
useKeyframeDrag,
@@ -12,14 +12,16 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation";
import {
- getTrackClasses,
+ getElementClasses,
getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
+ hasElementEffects,
hasMediaId,
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
+import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
import {
ContextMenu,
ContextMenuContent,
@@ -30,14 +32,22 @@ import {
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
- VisualElement,
ElementDragState,
-} from "@/types/timeline";
-import type { MediaAsset } from "@/types/assets";
+ VideoElement,
+ ImageElement,
+ AudioElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
+import {
+ getActionDefinition,
+ type TAction,
+ type TActionWithOptionalArgs,
+ invokeAction,
+} from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { resolveStickerId } from "@/lib/stickers";
+import { buildGraphicPreviewUrl } from "@/lib/graphics";
import Image from "next/image";
import {
ScissorIcon,
@@ -56,13 +66,16 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
import type { ComponentProps, ReactNode } from "react";
-import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation";
+import type {
+ SelectedKeyframeRef,
+ ElementKeyframe,
+} from "@/lib/animation/types";
import { cn } from "@/utils/ui";
-import { Button } from "@/components/ui/button";
-import { usePropertiesStore } from "@/stores/properties-store";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5;
+const THUMBNAIL_ASPECT_RATIO = 16 / 9;
interface KeyframeIndicator {
time: number;
@@ -195,16 +208,15 @@ export function TimelineElement({
dragState,
isDropTarget = false,
}: TimelineElementProps) {
- const editor = useEditor();
+ const mediaAssets = useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
- const { requestRevealMedia } = useAssetsPanelStore();
+ const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
let mediaAsset: MediaAsset | null = null;
if (hasMediaId(element)) {
mediaAsset =
- editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
- null;
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
}
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
@@ -394,60 +406,46 @@ function ElementInner({
}) => void;
isDropTarget?: boolean;
}) {
- const opacityClass =
- (canElementBeHidden(element) && element.hidden) || isDropTarget
- ? "opacity-50"
- : "";
- const closeClipEffects = usePropertiesStore(
- (state) => state.closeClipEffects,
- );
-
+ const isReducedOpacity =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget;
return (
<div
- className="relative h-full cursor-pointer"
- style={{ marginInline: ELEMENT_RING_WIDTH_PX }}
+ className="absolute top-0 bottom-0"
+ style={{
+ left: `${ELEMENT_RING_WIDTH_PX}px`,
+ right: `${ELEMENT_RING_WIDTH_PX}px`,
+ }}
>
<div
- className={cn(
- "absolute inset-0 overflow-hidden rounded-sm",
- getTrackClasses({ type: track.type }),
- opacityClass,
- )}
+ className="absolute inset-0 rounded-sm"
style={
isSelected
? {
- boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`,
+ boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
}
: undefined
}
>
- <button
- type="button"
- className="absolute inset-0 size-full cursor-pointer flex flex-col"
- onClick={(event) => {
- closeClipEffects();
- onElementClick(event, element);
- }}
- onMouseDown={(event) => onElementMouseDown(event, element)}
+ <div
+ className={cn(
+ "absolute inset-0 overflow-hidden rounded-sm",
+ getElementClasses({ type: track.type }),
+ isReducedOpacity && "opacity-50",
+ )}
>
- <div className="flex flex-1 min-h-0 items-center overflow-hidden">
- <ElementContent
- element={element}
- track={track}
- isSelected={isSelected}
- />
- </div>
- </button>
- </div>
-
- {element.type !== "audio" && element.type !== "effect" && (
- <div className="sticky left-1 mt-1 ml-1 w-fit">
- <EffectsButton
- element={element as VisualElement}
- trackId={track.id}
- />
+ <button
+ type="button"
+ tabIndex={-1}
+ className="absolute inset-0 size-full flex flex-col"
+ onClick={(event) => onElementClick(event, element)}
+ onMouseDown={(event) => onElementMouseDown(event, element)}
+ >
+ <div className="flex flex-1 min-h-0 items-center overflow-hidden">
+ <ElementContent element={element} track={track} />
+ </div>
+ </button>
</div>
- )}
+ </div>
{isSelected && (
<>
@@ -535,8 +533,8 @@ function KeyframeIndicators({
const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
isKeyframeSelected({ keyframe }),
);
- const isBeingDragged = indicator.keyframes.some((kf) =>
- dragState.draggingKeyframeIds.has(kf.keyframeId),
+ const isBeingDragged = indicator.keyframes.some((keyframe) =>
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
);
const visualOffsetPx = getVisualOffsetPx({
indicatorTime: indicator.time,
@@ -581,212 +579,244 @@ function KeyframeIndicators({
interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
- isSelected: boolean;
}
-interface ElementContentRendererProps extends ElementContentProps {
- mediaAssets: MediaAsset[];
+function TextElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "text" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start pl-2">
+ <span className="truncate text-xs text-white">{element.content}</span>
+ </div>
+ );
+}
+
+function EffectElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "effect" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start gap-1 pl-2">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-4 shrink-0 text-white"
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
}
-type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode;
+function StickerElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "sticker" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={resolveStickerId({
+ stickerId: element.stickerId,
+ options: { width: 20, height: 20 },
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
-export function renderTiledMedia({
+function GraphicElementContent({
element,
- imageUrl,
- track,
}: {
- element: VisualElement;
- imageUrl: string | undefined;
- track: ElementContentProps["track"];
-}): ReactNode {
- if (!imageUrl) {
+ element: Extract<TimelineElementType, { type: "graphic" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={buildGraphicPreviewUrl({
+ definitionId: element.definitionId,
+ params: element.params,
+ size: 20,
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function AudioElementContent({ element }: { element: AudioElement }) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const mediaAsset =
+ element.sourceType === "upload"
+ ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
+ : null;
+
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+ const audioUrl =
+ element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
+ const mediaLabel = mediaAsset?.name ?? element.name;
+
+ if (audioBuffer || audioUrl) {
return (
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
+ <div className="relative size-full">
+ <AudioWaveform
+ audioBuffer={audioBuffer}
+ audioUrl={audioUrl}
+ color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
+ />
+ <MediaElementHeader name={mediaLabel} hasFade={false} />
+ </div>
);
}
- const trackHeight = getTrackHeight({ type: track.type });
- const tileWidth = trackHeight * (16 / 9);
-
return (
- <div
- className="absolute inset-0"
- style={{
- backgroundImage: `url(${imageUrl})`,
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${trackHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- />
+ <span className="text-foreground/80 truncate text-xs">{element.name}</span>
);
}
function EffectsButton({
element,
- trackId,
- className,
+ track,
}: {
- element: VisualElement;
- trackId: string;
- className?: string;
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
}) {
- const openClipEffects = usePropertiesStore((state) => state.openClipEffects);
- const { selectElement } = useElementSelection();
-
- if (!element.effects?.length) {
- return null;
- }
+ const editor = useEditor();
+ const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
const handleClick = (event: React.MouseEvent) => {
event.stopPropagation();
- selectElement({ elementId: element.id, trackId });
- openClipEffects({ elementId: element.id, trackId });
+ editor.selection.setSelectedElements({
+ elements: [{ trackId: track.id, elementId: element.id }],
+ });
+ setActiveTab(element.type, "effects");
};
return (
- <Button
- variant="text"
- size="icon"
- className={cn("rounded-sm !size-5 bg-black/50 text-white", className)}
- onClick={handleClick}
+ <button
+ type="button"
+ className="flex shrink-0 justify-center text-white cursor-pointer"
onMouseDown={(event) => event.stopPropagation()}
+ onClick={handleClick}
>
- <HugeiconsIcon icon={MagicWand05Icon} />
- </Button>
+ <HugeiconsIcon icon={MagicWand05Icon} size={12} />
+ </button>
);
}
-const ELEMENT_CONTENT_RENDERERS: Record<
- TimelineElementType["type"],
- ElementContentRenderer
-> = {
- text: ({ element }) => {
- const textElement = element as Extract<
- TimelineElementType,
- { type: "text" }
- >;
- return (
- <div className="flex size-full items-center justify-start pl-2">
- <span className="truncate text-xs text-white">
- {textElement.content}
- </span>
- </div>
- );
- },
- effect: ({ element }) => (
- <div className="flex size-full items-center justify-start gap-1 pl-2">
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="size-4 shrink-0 text-white"
- />
- <span className="truncate text-xs text-white ml-1">{element.name}</span>
- </div>
- ),
- sticker: ({ element }) => {
- const stickerElement = element as Extract<
- TimelineElementType,
- { type: "sticker" }
- >;
- return (
- <div className="flex size-full items-center gap-2 pl-2">
- <Image
- src={resolveStickerId({
- stickerId: stickerElement.stickerId,
- options: { width: 20, height: 20 },
- })}
- alt={stickerElement.name}
- className="size-5 shrink-0"
- width={20}
- height={20}
- unoptimized
- />
- <span className="truncate text-xs text-white">
- {stickerElement.name}
- </span>
- </div>
- );
- },
- audio: ({ element, mediaAssets }) => {
- const audioElement = element as Extract<
- TimelineElementType,
- { type: "audio" }
- >;
- const audioBuffer =
- audioElement.sourceType === "library" ? audioElement.buffer : undefined;
- const audioUrl =
- audioElement.sourceType === "library"
- ? audioElement.sourceUrl
- : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url;
+function TiledMediaContent({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
- 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>
- );
- }
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ const imageUrl =
+ element.type === "video"
+ ? mediaAsset?.thumbnailUrl
+ : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
+ if (!imageUrl) {
return (
<span className="text-foreground/80 truncate text-xs">
- {audioElement.name}
+ {element.name}
</span>
);
- },
- video: ({ element, track, mediaAssets }) => {
- const videoElement = element as Extract<
- TimelineElementType,
- { type: "video" }
- >;
- const mediaAsset = mediaAssets.find(
- (asset) => asset.id === videoElement.mediaId,
- );
- return renderTiledMedia({
- element: videoElement,
- imageUrl: mediaAsset?.thumbnailUrl,
- track,
- });
- },
- image: ({ element, track, mediaAssets }) => {
- const imageElement = element as Extract<
- TimelineElementType,
- { type: "image" }
- >;
- const mediaAsset = mediaAssets.find(
- (asset) => asset.id === imageElement.mediaId,
- );
- return renderTiledMedia({
- element: imageElement,
- imageUrl: mediaAsset?.url,
- track,
- });
- },
-};
+ }
+
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
-function ElementContent({ element, track, isSelected }: ElementContentProps) {
- const editor = useEditor();
- const renderer = ELEMENT_CONTENT_RENDERERS[element.type];
return (
<>
- {renderer({
- element,
- track,
- isSelected,
- mediaAssets: editor.media.getAssets(),
- })}
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundColor: "var(--muted)",
+ backgroundImage: `url(${imageUrl})`,
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ />
+ <MediaElementHeader
+ name={mediaAsset?.name}
+ leading={
+ hasElementEffects({ element }) ? (
+ <EffectsButton element={element} track={track} />
+ ) : null
+ }
+ hasFade={true}
+ />
</>
);
}
+function MediaElementHeader({
+ name,
+ leading,
+ hasFade,
+}: {
+ name?: string | null;
+ leading?: ReactNode;
+ hasFade?: boolean;
+}) {
+ if (!name && !leading) {
+ return null;
+ }
+
+ return (
+ <div
+ className={cn(
+ "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1",
+ hasFade && "from-black/30 to-transparent",
+ )}
+ >
+ {leading && <div className="pl-1">{leading}</div>}
+ {name && (
+ <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
+ {name}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function ElementContent({ element, track }: ElementContentProps) {
+ switch (element.type) {
+ case "text":
+ return <TextElementContent element={element} />;
+ case "effect":
+ return <EffectElementContent element={element} />;
+ case "sticker":
+ return <StickerElementContent element={element} />;
+ case "graphic":
+ return <GraphicElementContent element={element} />;
+ case "audio":
+ return <AudioElementContent element={element} />;
+ case "video":
+ case "image":
+ return <TiledMediaContent element={element} track={track} />;
+ }
+}
+
function CopyMenuItem() {
return (
<ActionMenuItem
@@ -886,7 +916,7 @@ function ActionMenuItem({
children,
...props
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
- action: TAction;
+ action: TActionWithOptionalArgs;
children: ReactNode;
}) {
return (
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
index e290259e..1abe65bd 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -8,9 +8,14 @@ import {
} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
+import {
+ TIMELINE_LAYERS,
+ TIMELINE_SCROLLBAR_SIZE_PX,
+} from "@/constants/timeline-constants";
interface TimelinePlayheadProps {
zoomLevel: number;
+ hasHorizontalScrollbar: boolean;
rulerRef: React.RefObject<HTMLDivElement | null>;
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
@@ -21,6 +26,7 @@ interface TimelinePlayheadProps {
export function TimelinePlayhead({
zoomLevel,
+ hasHorizontalScrollbar,
rulerRef,
rulerScrollRef,
tracksScrollRef,
@@ -33,7 +39,7 @@ export function TimelinePlayhead({
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ const { handlePlayheadMouseDown } = useTimelinePlayhead({
zoomLevel,
rulerRef,
rulerScrollRef,
@@ -42,16 +48,23 @@ export function TimelinePlayhead({
});
const timelineContainerHeight =
- tracksScrollRef.current?.clientHeight ??
timelineRef.current?.clientHeight ??
+ tracksScrollRef.current?.clientHeight ??
400;
- const totalHeight = Math.max(0, timelineContainerHeight - 4);
+ const totalHeight = Math.max(
+ 0,
+ timelineContainerHeight -
+ (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0),
+ );
+ const currentTime = editor.playback.getCurrentTime();
const centerPosition = timelineTimeToSnappedPixels({
- time: playheadPosition,
+ time: currentTime,
zoomLevel,
});
- const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
+ const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
+ const leftPosition =
+ getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft;
const handlePlayheadKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>,
@@ -61,10 +74,8 @@ export function TimelinePlayhead({
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),
- );
+ const now = editor.playback.getCurrentTime();
+ const nextTime = Math.max(0, Math.min(duration, now + direction * step));
editor.playback.seek({ time: nextTime });
};
@@ -76,23 +87,24 @@ export function TimelinePlayhead({
aria-label="Timeline playhead"
aria-valuemin={0}
aria-valuemax={duration}
- aria-valuenow={playheadPosition}
+ aria-valuenow={currentTime}
tabIndex={0}
- className="pointer-events-none absolute z-5"
+ className="pointer-events-none absolute"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.playhead,
}}
onKeyDown={handlePlayheadKeyDown}
>
- <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" />
+ <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" />
<button
type="button"
aria-label="Drag playhead"
- className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
+ className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`}
onMouseDown={handlePlayheadMouseDown}
/>
</div>
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
index b0261aa2..09562f23 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
@@ -1,5 +1,8 @@
import { type JSX, useLayoutEffect, useRef } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ TIMELINE_CONSTANTS,
+ TIMELINE_RULER_HEIGHT,
+} 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";
@@ -27,13 +30,12 @@ export function TimelineRuler({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineRulerProps) {
- const editor = useEditor();
- const duration = editor.timeline.getTotalDuration();
+ const duration = useEditor((e) => e.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 fps =
+ useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
zoomLevel,
fps,
@@ -104,17 +106,25 @@ export function TimelineRuler({
aria-valuemin={0}
aria-valuemax={effectiveDuration}
aria-valuenow={0}
- className="relative h-4 flex-1 overflow-x-visible"
+ className="relative flex-1 overflow-x-visible"
+ style={{ height: TIMELINE_RULER_HEIGHT }}
onWheel={handleWheel}
- onClick={handleTimelineContentClick}
+ onClick={(event) => {
+ // Ruler seek already happens on mousedown via playhead scrubbing.
+ // Forwarding the follow-up click re-enters the selection-clearing path.
+ if (event.target === event.currentTarget) {
+ handleTimelineContentClick(event);
+ }
+ }}
onMouseDown={handleRulerTrackingMouseDown}
onKeyDown={() => {}}
>
<div
role="none"
ref={rulerRef}
- className="relative h-4 cursor-default select-none"
+ className="relative cursor-default select-none"
style={{
+ height: TIMELINE_RULER_HEIGHT,
width: `${dynamicTimelineWidth}px`,
}}
onMouseDown={handleRulerMouseDown}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
index 22cd52cf..29fd513b 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
@@ -22,7 +22,7 @@ export function TimelineTick({
const label = formatRulerLabel({ timeInSeconds: time, fps });
return (
<span
- className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none"
+ className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
style={{ left: `${leftPosition}px` }}
>
{label}
@@ -32,7 +32,7 @@ export function TimelineTick({
return (
<div
- className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l"
+ className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
style={{ left: `${leftPosition}px` }}
/>
);
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
index 533ea63d..e785d147 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -16,8 +16,8 @@ import {
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 { ScenesView } from "@/components/editor/scenes-view";
+import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -76,15 +76,15 @@ export function TimelineToolbar({
}
function ToolbarLeftSection() {
- const editor = useEditor();
- const currentTime = editor.playback.getCurrentTime();
- const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime });
+ const isCurrentlyBookmarked = useEditor((e) =>
+ e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
+ );
const handleAction = ({
action,
event,
}: {
- action: TAction;
+ action: TActionWithOptionalArgs;
event: React.MouseEvent;
}) => {
event.stopPropagation();
@@ -149,8 +149,8 @@ function ToolbarLeftSection() {
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
- isActive={isCurrentlyBookmarked}
- tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
+ isActive={isCurrentlyBookmarked}
+ tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event })
}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
index 81cda7a6..6a9e4dfe 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -2,12 +2,12 @@
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 { TimelineTrack } from "@/lib/timeline";
+import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import type { ElementDragState } from "@/types/timeline";
+import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
interface TimelineTrackContentProps {
@@ -30,7 +30,7 @@ interface TimelineTrackContentProps {
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
- onTrackClick?: (event: React.MouseEvent) => void;
+ onTrackMouseUp?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
targetElementId?: string | null;
}
@@ -47,14 +47,12 @@ export function TimelineTrackContent({
onElementMouseDown,
onElementClick,
onTrackMouseDown,
- onTrackClick,
+ onTrackMouseUp,
shouldIgnoreClick,
targetElementId = null,
}: TimelineTrackContentProps) {
- const editor = useEditor();
const { isElementSelected } = useElementSelection();
-
- const duration = editor.timeline.getTotalDuration();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
useEdgeAutoScroll({
isActive: dragState.isDragging,
@@ -65,21 +63,37 @@ export function TimelineTrackContent({
});
return (
- <button
- className="size-full"
- onClick={(event) => {
- if (shouldIgnoreClick?.()) return;
- onTrackClick?.(event);
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- type="button"
- >
- <div className="relative h-full min-w-full">
+ <div className="relative size-full">
+ <button
+ type="button"
+ className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
+ aria-label={`Select ${track.name} track`}
+ onMouseUp={(event) => {
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
+ <div
+ className="relative h-full min-w-full"
+ style={{ zIndex: TIMELINE_LAYERS.trackContent }}
+ onMouseUp={(event) => {
+ if (event.target !== event.currentTarget) return;
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ >
{track.elements.length === 0 ? (
- <div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
+ <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
) : (
track.elements.map((element) => {
const isSelected = isElementSelected({
@@ -109,6 +123,6 @@ export function TimelineTrackContent({
})
)}
</div>
- </button>
+ </div>
);
}
diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx
deleted file mode 100644
index 0b757e36..00000000
--- a/apps/web/src/components/editor/selection-box.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-
-interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject<HTMLElement | null>;
- isActive: boolean;
-}
-
-export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
-}: SelectionBoxProps) {
- const selectionBoxStyle = useMemo(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) {
- return null;
- }
-
- const containerRect = containerRef.current.getBoundingClientRect();
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- return {
- left: `${left}px`,
- top: `${top}px`,
- width: `${width}px`,
- height: `${height}px`,
- };
- }, [containerRef, currentPos, isActive, startPos]);
-
- if (!selectionBoxStyle) return null;
-
- return (
- <div
- style={selectionBoxStyle}
- className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
- />
- );
-}
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index 746cfd16..35e5dfba 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -3,13 +3,12 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
+import { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
-import {
- useKeybindingsListener,
- useKeybindingDisabler,
-} from "@/hooks/use-keybindings";
+import { useKeybindingsListener } from "@/hooks/use-keybindings";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
-import { prefetchFontAtlas } from "@/lib/fonts/google-fonts";
+import { loadFontAtlas } from "@/lib/fonts/google-fonts";
interface EditorProviderProps {
projectId: string;
@@ -17,23 +16,19 @@ interface EditorProviderProps {
}
export function EditorProvider({ projectId, children }: EditorProviderProps) {
- const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActiveOrNull());
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
- const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
- const activeProject = editor.project.getActiveOrNull();
+ const { setLoadingProject } = useKeybindingsStore();
useEffect(() => {
- if (isLoading) {
- disableKeybindings();
- } else {
- enableKeybindings();
- }
- }, [isLoading, disableKeybindings, enableKeybindings]);
+ setLoadingProject(isLoading);
+ }, [isLoading, setLoadingProject]);
useEffect(() => {
let cancelled = false;
+ const editor = EditorCore.getInstance();
const loadProject = async () => {
try {
@@ -43,7 +38,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
if (cancelled) return;
setIsLoading(false);
- prefetchFontAtlas();
+ loadFontAtlas();
} catch (err) {
if (cancelled) return;
@@ -76,7 +71,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
return () => {
cancelled = true;
};
- }, [projectId, editor, router]);
+ }, [projectId, router]);
if (error) {
return (
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/section.tsx
index 5989108b..e3fc96df 100644
--- a/apps/web/src/components/editor/panels/properties/section.tsx
+++ b/apps/web/src/components/section.tsx
@@ -36,7 +36,7 @@ export function Section({
defaultOpen = true,
sectionKey,
className,
- showTopBorder = true,
+ showTopBorder = false,
showBottomBorder = true,
}: SectionProps) {
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
@@ -44,11 +44,18 @@ export function Section({
useEffect(() => {
if (!sectionKey) return;
- if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) {
- console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`);
+ if (
+ process.env.NODE_ENV !== "production" &&
+ mountedSectionKeys.has(sectionKey)
+ ) {
+ console.error(
+ `[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`,
+ );
}
mountedSectionKeys.add(sectionKey);
- return () => { mountedSectionKeys.delete(sectionKey); };
+ return () => {
+ mountedSectionKeys.delete(sectionKey);
+ };
}, [sectionKey]);
const toggle = () => {
@@ -62,8 +69,8 @@ export function Section({
<div
className={cn(
"flex flex-col",
- showTopBorder && "border-t",
- showBottomBorder && "last:border-b",
+ showTopBorder && "border-t first:border-t-0",
+ showBottomBorder && "border-b",
className,
)}
>
@@ -96,63 +103,58 @@ export function SectionHeader({
const isInteractive = isCollapsible || !!onClick;
const handleClick = isCollapsible ? ctx?.toggle : onClick;
- const chevronIcon = isCollapsible ? (
+ const chevronIcon = (
<HugeiconsIcon
icon={ArrowDownIcon}
className={cn(
"size-4 shrink-0 transition-transform duration-200 ease-out",
- isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground",
+ isOpen
+ ? "rotate-0 text-foreground"
+ : "-rotate-90 text-muted-foreground",
)}
/>
- ) : null;
+ );
+
+ const trailingArea =
+ trailing || isCollapsible ? (
+ <div className="flex items-center">
+ {trailing}
+ {isCollapsible && (
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={isOpen ? "Collapse section" : "Expand section"}
+ onClick={handleClick}
+ >
+ {chevronIcon}
+ </Button>
+ )}
+ </div>
+ ) : null;
- const headerContent = (
+ const innerContent = isInteractive ? (
+ <button
+ type="button"
+ className="min-w-0 flex-1 flex items-center gap-2 h-full cursor-pointer text-left"
+ onClick={handleClick}
+ >
+ {leading}
+ <div className="min-w-0 flex-1 flex items-center">{children}</div>
+ </button>
+ ) : (
<>
{leading}
- <div className="min-w-0 flex-1">{children}</div>
- {(trailing || chevronIcon) && (
- <div className="flex items-center">
- {trailing}
- {chevronIcon && (
- <Button
- variant="ghost"
- size="icon"
- aria-label={isOpen ? "Collapse section" : "Expand section"}
- onClick={(event) => {
- event.stopPropagation();
- handleClick?.();
- }}
- >
- {chevronIcon}
- </Button>
- )}
- </div>
- )}
- {actions}
+ <div className="min-w-0 flex-1 flex items-center">{children}</div>
</>
);
- if (!isInteractive) {
- return (
- <div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}>
- {headerContent}
- </div>
- );
- }
-
return (
- // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here
<div
- role="button"
- tabIndex={0}
- className={cn(
- "flex h-11 w-full cursor-pointer items-center gap-2 px-3.5",
- className,
- )}
- onClick={handleClick}
- onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }}
+ className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}
>
- {headerContent}
+ {innerContent}
+ {trailingArea}
+ {actions}
</div>
);
}
@@ -167,17 +169,20 @@ export function SectionTitle({
onClick?: () => void;
}) {
const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
+ const titleClass = cn(
+ "text-sm font-medium",
+ isCollapsible && isOpen ? "text-foreground" : "text-muted-foreground",
+ className,
+ );
+
if (onClick) {
return (
<button
type="button"
- className={cn(
- "cursor-pointer text-sm font-medium",
- isOpen ? "text-foreground" : "text-muted-foreground",
- className,
- )}
+ className={cn("cursor-pointer", titleClass)}
onClick={onClick}
>
{children}
@@ -185,17 +190,7 @@ export function SectionTitle({
);
}
- return (
- <span
- className={cn(
- "text-sm font-medium",
- isOpen ? "text-foreground" : "text-muted-foreground",
- className,
- )}
- >
- {children}
- </span>
- );
+ return <span className={titleClass}>{children}</span>;
}
export function SectionFields({
@@ -243,6 +238,8 @@ export function SectionContent({
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
+ const content = <div className={cn("p-4 pt-0", className)}>{children}</div>;
+
if (isCollapsible) {
return (
<div
@@ -251,12 +248,10 @@ export function SectionContent({
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
- <div className="overflow-hidden">
- <div className={cn("p-4 pt-0", className)}>{children}</div>
- </div>
+ <div className="overflow-hidden">{content}</div>
</div>
);
}
- return <div className={cn("p-4 pt-0", className)}>{children}</div>;
+ return content;
}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index aa52e228..3dff9417 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -23,7 +23,7 @@ const buttonVariants = cva(
},
size: {
default: "h-9 px-4 py-2",
- sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm",
+ sm: "h-7 p-1 px-2.5 text-sm rounded-sm",
lg: "h-10 p-5 px-6",
icon: "size-7 rounded-sm",
text: "p-0",
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
index fbf9bbd7..6d911a75 100644
--- a/apps/web/src/components/ui/color-picker.tsx
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -270,6 +270,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
backgroundColor: "#fff",
};
+
return (
<Popover>
<div
@@ -285,10 +286,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
type="button"
>
- <span
- className="absolute inset-0"
- style={checkerboardStyle}
- />
+ <span
+ className="absolute inset-0 dark:invert"
+ style={checkerboardStyle}
+ />
<span
className="absolute inset-0"
style={{ backgroundColor: `#${value}` }}
@@ -298,7 +299,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
<div className="flex flex-1 items-center">
<Input
className={cn(
- "!border-0 bg-transparent p-0 !ring-0 !ring-offset-0",
+ "border-0! bg-transparent p-0 ring-0! ring-offset-0!",
colorFormat === "hex" && "uppercase",
)}
size="sm"
@@ -387,10 +388,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
<button
ref={opacityRef}
className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
- style={checkerboardStyle}
type="button"
onMouseDown={handleOpacityMouseDown}
>
+ <div className="absolute inset-0 dark:invert" style={checkerboardStyle} />
<div
className="absolute inset-0 rounded-lg"
style={{
diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx
index d8791bdf..ec02de6b 100644
--- a/apps/web/src/components/ui/context-menu.tsx
+++ b/apps/web/src/components/ui/context-menu.tsx
@@ -10,8 +10,20 @@ import {
ArrowRightIcon,
CircleIcon,
} from "@hugeicons/core-free-icons";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const ContextMenu = ContextMenuPrimitive.Root;
+function ContextMenu({
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "context-menu",
+ onOpenChange,
+ });
+ return (
+ <ContextMenuPrimitive.Root onOpenChange={handleOpenChange} {...props} />
+ );
+}
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index d124a2e9..f5045f95 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -4,8 +4,22 @@ import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react";
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Dialog = DialogPrimitive.Root;
+function Dialog({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dialog",
+ open,
+ onOpenChange,
+ });
+ return (
+ <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
const DialogTrigger = DialogPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index 2d6541b8..91913e25 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -4,10 +4,27 @@ import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
-
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const DropdownMenu = DropdownMenuPrimitive.Root;
+function DropdownMenu({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dropdown-menu",
+ open,
+ onOpenChange,
+ });
+ return (
+ <DropdownMenuPrimitive.Root
+ open={open}
+ onOpenChange={handleOpenChange}
+ {...props}
+ />
+ );
+}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -118,7 +135,7 @@ const DropdownMenuItem = React.forwardRef<
ref,
) => {
const iconSlot = (
- <span className="hidden size-4 shrink-0 items-center justify-center group-has-[[data-has-icon]]/menu:flex">
+ <span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex">
{icon}
</span>
);
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index 09ba0a9a..77d8ab7f 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -1,13 +1,6 @@
"use client";
-import {
- useState,
- useMemo,
- useRef,
- useEffect,
- useCallback,
- type CSSProperties,
-} from "react";
+import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties } from "react";
import { List, type RowComponentProps } from "react-window";
import {
Popover,
@@ -16,28 +9,24 @@ import {
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
-import {
- getCachedFontAtlas,
- loadFullFont,
- prefetchFontAtlas,
- clearFontAtlasCache,
-} from "@/lib/fonts/google-fonts";
-import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
+import { loadFullFont } from "@/lib/fonts/google-fonts";
+import { SYSTEM_FONTS } from "@/constants/font-constants";
+import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types";
+import { useFontAtlas } from "@/hooks/use-font-atlas";
import { cn } from "@/utils/ui";
-import { ChevronDown, Search, Upload } from "lucide-react";
+import { ChevronDown, Search } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { TextIcon } from "@hugeicons/core-free-icons";
const FONT_TABS = [
{ key: "all", label: "All fonts" },
- { key: "favorites", label: "Favorites" },
{ key: "my-fonts", label: "My fonts" },
+ { key: "favorites", label: "Favorites" },
] as const;
type FontTab = (typeof FONT_TABS)[number]["key"];
const ROW_HEIGHT = 40;
-const SPRITE_ROW_HEIGHT = 40;
const PREVIEW_SCALE = 0.8;
const LIST_WIDTH = 288;
const MAX_LIST_HEIGHT = 288;
@@ -57,18 +46,8 @@ export function FontPicker({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState<FontTab>("all");
- const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
- getCachedFontAtlas(),
- );
- const [status, setStatus] = useState<"idle" | "loading" | "error">(() =>
- getCachedFontAtlas() ? "idle" : "loading",
- );
const searchInputRef = useRef<HTMLInputElement>(null);
-
- const fontNames = useMemo(() => {
- if (!atlas) return [];
- return Object.keys(atlas.fonts).sort();
- }, [atlas]);
+ const { atlas, status, fontNames, retry: handleRetry } = useFontAtlas({ open });
const filteredFonts = useMemo(() => {
if (!search) return fontNames;
@@ -83,32 +62,19 @@ export function FontPicker({
const handleSelect = useCallback(
async ({ family }: { family: string }) => {
- try {
- await loadFullFont({ family });
- onValueChange?.(family);
- } catch {
- onValueChange?.(family);
+ if (!SYSTEM_FONTS.has(family)) {
+ try {
+ await loadFullFont({ family });
+ } catch {
+ // ignore load failure, font will fall back to system default
+ }
}
+ onValueChange?.(family);
setOpen(false);
},
[onValueChange],
);
- // Load atlas on first open if cache is empty (fallback when prefetch hasn't completed)
- useEffect(() => {
- if (!open || atlas) return;
-
- setStatus("loading");
- prefetchFontAtlas().then((data) => {
- if (data) {
- setAtlas(data);
- setStatus("idle");
- } else {
- setStatus("error");
- }
- });
- }, [open, atlas]);
-
useEffect(() => {
if (!open) {
setSearch("");
@@ -116,18 +82,8 @@ export function FontPicker({
}
}, [open]);
- const handleRetry = useCallback(() => {
- clearFontAtlasCache();
- setStatus("loading");
- prefetchFontAtlas().then((data) => {
- if (data) {
- setAtlas(data);
- setStatus("idle");
- } else {
- setStatus("error");
- }
- });
- }, []);
+ const activeTabLabel =
+ FONT_TABS.find((t) => t.key === activeTab)?.label.toLowerCase() ?? "";
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -164,11 +120,11 @@ export function FontPicker({
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" />
<Input
ref={searchInputRef}
- placeholder="Search fonts..."
+ placeholder={`Search ${activeTabLabel}...`}
value={search}
onChange={(event) => setSearch(event.target.value)}
size="xs"
- className="w-full pl-5 bg-transparent !border-none !shadow-none"
+ className="w-full pl-5 bg-transparent border-none! shadow-none!"
/>
</div>
<div className="flex border-b px-3">
@@ -225,20 +181,6 @@ export function FontPicker({
style={{ height: listHeight, width: LIST_WIDTH }}
/>
)}
- <div className="border-t p-1">
- <Button
- variant="ghost"
- size="sm"
- className="w-full justify-start text-muted-foreground h-8 font-normal"
- onClick={() => {
- // TODO: Implement local font loading
- console.log("Load local fonts clicked");
- }}
- >
- <Upload className="!size-3.5" />
- Load local fonts
- </Button>
- </div>
</PopoverContent>
</Popover>
);
@@ -250,7 +192,7 @@ function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) {
className="shrink-0"
style={{
width: entry.w,
- height: SPRITE_ROW_HEIGHT,
+ height: ROW_HEIGHT,
backgroundColor: "currentColor",
WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
WebkitMaskPosition: `-${entry.x}px -${entry.y}px`,
@@ -283,6 +225,7 @@ function FontRow({
const fontName = filteredFonts[index];
const entry = atlas.fonts[fontName];
const isSelected = fontName === selectedFont;
+ const isSystemFont = SYSTEM_FONTS.has(fontName);
return (
<button
@@ -302,7 +245,13 @@ function FontRow({
aria-label={fontName}
>
<div className="min-w-0 overflow-hidden">
- <FontSpritePreview entry={entry} />
+ {isSystemFont ? (
+ <span className="text-xl text-foreground/85" style={{ fontFamily: fontName }}>
+ {fontName}
+ </span>
+ ) : (
+ <FontSpritePreview entry={entry} />
+ )}
</div>
</button>
);
diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx
deleted file mode 100644
index 1c22fd2e..00000000
--- a/apps/web/src/components/ui/input-with-back.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { ArrowLeft, Search } from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-
-interface InputWithBackProps {
- isExpanded: boolean;
- setIsExpanded: (isExpanded: boolean) => void;
- placeholder?: string;
- value?: string;
- onChange?: (value: string) => void;
- disableAnimation?: boolean;
-}
-
-export function InputWithBack({
- isExpanded,
- setIsExpanded,
- placeholder = "Search anything",
- value,
- onChange,
- disableAnimation = false,
-}: InputWithBackProps) {
- const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
- const [buttonOffset, setButtonOffset] = useState(-60);
-
- const smoothTransition = {
- duration: disableAnimation ? 0 : 0.35,
- ease: [0.25, 0.1, 0.25, 1] as const,
- };
-
- useEffect(() => {
- if (containerRef) {
- const rect = containerRef.getBoundingClientRect();
- setButtonOffset(-rect.left - 48);
- }
- }, [containerRef]);
-
- return (
- <div ref={setContainerRef} className="relative w-full">
- <motion.div
- className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
- initial={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- animate={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- transition={smoothTransition}
- onClick={() => setIsExpanded(!isExpanded)}
- >
- <Button variant="outline" className="bg-accent !size-9 rounded-full">
- <ArrowLeft />
- </Button>
- </motion.div>
- <div
- className="relative flex-1"
- style={{ marginLeft: "0px", paddingLeft: "0px" }}
- >
- <motion.div
- className="relative"
- initial={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- animate={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- transition={smoothTransition}
- >
- <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
- <Input
- placeholder={placeholder}
- className="bg-accent w-full pl-9"
- value={value}
- onChange={(e) => onChange?.(e.target.value)}
- />
- </motion.div>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index 762cc675..1dce2f24 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -8,18 +8,19 @@ import { forwardRef, type ComponentProps } from "react";
import { useState } from "react";
const inputVariants = cva(
- "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
- default: "selection:bg-primary selection:text-primary-foreground",
+ default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20",
+ outline: "bg-background",
destructive:
"selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
},
size: {
default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
xs: "h-7 px-3 text-xs file:h-6 file:text-xs",
- sm: "h-8 px-3 text-sm file:h-6 file:text-xs",
+ sm: "h-7 px-3 text-sm file:h-6 file:text-xs",
lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
},
},
diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx
index 48297dc0..18d71591 100644
--- a/apps/web/src/components/ui/number-field.tsx
+++ b/apps/web/src/components/ui/number-field.tsx
@@ -1,12 +1,15 @@
"use client";
import { cn } from "@/utils/ui";
-import { useRef, useState, type ComponentProps } from "react";
+import { clamp } from "@/utils/math";
+import { useRef, useState, useLayoutEffect, type ComponentProps } from "react";
import { useFocusLock } from "@/hooks/use-focus-lock";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons";
+const SUFFIX_GAP_PX = 6;
+
const DRAG_SENSITIVITIES = {
default: 1,
slow: 0.5,
@@ -14,10 +17,94 @@ const DRAG_SENSITIVITIES = {
type DragSensitivity = "default" | "slow";
+type ScrubRange = {
+ from: number;
+ to: number;
+ pixelsPerUnit: number;
+};
+
+type ScrubClamp = {
+ min?: number;
+ max?: number;
+};
+
+function clampScrubValue({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min?: number;
+ max?: number;
+}): number {
+ if (min != null && max != null) return clamp({ value, min, max });
+ if (min != null) return Math.max(min, value);
+ if (max != null) return Math.min(max, value);
+ return value;
+}
+
+function getActiveRange({
+ value,
+ direction,
+ ranges,
+}: {
+ value: number;
+ direction: number;
+ ranges: readonly ScrubRange[];
+}): ScrubRange | undefined {
+ return ranges.find((range) =>
+ direction > 0
+ ? value >= range.from && value < range.to
+ : value > range.from && value <= range.to,
+ );
+}
+
+function scrubAcrossRanges({
+ startValue,
+ pixelDelta,
+ ranges,
+ min,
+ max,
+}: {
+ startValue: number;
+ pixelDelta: number;
+ ranges: readonly ScrubRange[];
+ min?: number;
+ max?: number;
+}): number {
+ let currentValue = clampScrubValue({ value: startValue, min, max });
+ let remainingPixels = pixelDelta;
+
+ while (remainingPixels !== 0) {
+ const direction = Math.sign(remainingPixels);
+
+ const range = getActiveRange({ value: currentValue, direction, ranges });
+ if (!range) break;
+
+ const boundary = direction > 0 ? range.to : range.from;
+ const pixelsToBoundary =
+ Math.abs(boundary - currentValue) * range.pixelsPerUnit;
+
+ if (Math.abs(remainingPixels) <= pixelsToBoundary) {
+ currentValue += remainingPixels / range.pixelsPerUnit;
+ break;
+ }
+
+ currentValue = boundary;
+ remainingPixels -= direction * pixelsToBoundary;
+ }
+
+ return clampScrubValue({ value: currentValue, min, max });
+}
+
interface NumberFieldProps
extends Omit<ComponentProps<"input">, "size" | "type"> {
icon?: React.ReactNode;
+ suffix?: string;
+ suffixClassName?: string;
dragSensitivity?: DragSensitivity;
+ scrubRanges?: readonly ScrubRange[];
+ scrubClamp?: ScrubClamp;
onScrub?: (value: number) => void;
onScrubEnd?: () => void;
allowExpressions?: boolean;
@@ -28,8 +115,12 @@ interface NumberFieldProps
function NumberField({
className,
icon,
+ suffix,
+ suffixClassName,
disabled,
dragSensitivity = "default",
+ scrubRanges,
+ scrubClamp,
onScrub,
onScrubEnd,
value,
@@ -45,9 +136,18 @@ function NumberField({
}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) {
const iconRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
+ const ghostRef = useRef<HTMLSpanElement>(null);
const startValueRef = useRef(0);
const cumulativeDeltaRef = useRef(0);
const [isInputFocused, setIsInputFocused] = useState(false);
+ const [suffixLeft, setSuffixLeft] = useState(0);
+
+ useLayoutEffect(() => {
+ if (!suffix || !ghostRef.current || !inputRef.current) return;
+ const paddingLeft =
+ parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0;
+ setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth);
+ }, [value, suffix]);
const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({
isActive: isInputFocused,
@@ -71,12 +171,16 @@ function NumberField({
return;
}
cumulativeDeltaRef.current += moveEvent.movementX;
- const sensitivity =
- typeof dragSensitivity === "number"
- ? dragSensitivity
- : DRAG_SENSITIVITIES[dragSensitivity];
- const newValue =
- startValueRef.current + cumulativeDeltaRef.current * sensitivity;
+ const newValue = scrubRanges
+ ? scrubAcrossRanges({
+ startValue: startValueRef.current,
+ pixelDelta: cumulativeDeltaRef.current,
+ ranges: scrubRanges,
+ min: scrubClamp?.min,
+ max: scrubClamp?.max,
+ })
+ : startValueRef.current +
+ cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
onScrub(newValue);
};
@@ -93,11 +197,48 @@ function NumberField({
const canScrub = Boolean(icon && onScrub);
+ const inputNode = (
+ <input
+ type={allowExpressions ? "text" : "number"}
+ inputMode={allowExpressions ? "decimal" : undefined}
+ ref={inputRef}
+ disabled={disabled}
+ value={value}
+ className="text-sm leading-none bg-transparent outline-none min-w-0 flex-1 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ onMouseDown={(event) => {
+ const inputElement = event.currentTarget;
+ const shouldPreventNativeCaretPlacement =
+ event.button === 0 && document.activeElement !== inputElement;
+ if (shouldPreventNativeCaretPlacement) {
+ event.preventDefault();
+ inputElement.focus();
+ inputElement.select();
+ }
+ onMouseDown?.(event);
+ }}
+ onFocus={(event) => {
+ setIsInputFocused(true);
+ event.currentTarget.select();
+ onFocus?.(event);
+ }}
+ onKeyDown={(event) => {
+ const shouldBlurInput = event.key === "Enter" || event.key === "Escape";
+ if (shouldBlurInput) event.currentTarget.blur();
+ onKeyDown?.(event);
+ }}
+ onBlur={(event) => {
+ setIsInputFocused(false);
+ onBlur?.(event);
+ }}
+ {...props}
+ />
+ );
+
return (
<div
ref={wrapperRef}
className={cn(
- "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
+ "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none cursor-text disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
disabled && "pointer-events-none cursor-not-allowed opacity-50",
className,
)}
@@ -106,53 +247,45 @@ function NumberField({
<span
ref={iconRef}
className={cn(
- "text-muted-foreground [&_svg]:!size-3.5 shrink-0 select-none pl-2.5 text-sm leading-none",
+ "text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none",
canScrub && "cursor-ew-resize",
)}
+ onMouseDown={canScrub ? (event) => event.preventDefault() : undefined}
onPointerDown={canScrub ? handleIconPointerDown : undefined}
>
{icon}
</span>
)}
- <input
- type={allowExpressions ? "text" : "number"}
- inputMode={allowExpressions ? "decimal" : undefined}
- ref={inputRef}
- disabled={disabled}
- value={value}
+ <span
className={cn(
- "min-w-0 flex-1 text-sm leading-none bg-transparent outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
+ "relative flex flex-1 min-w-0 items-center",
icon ? "px-1.5" : "pl-2.5",
onReset ? "pr-0" : "pr-2.5",
)}
- onMouseDown={(event) => {
- const inputElement = event.currentTarget;
- const shouldPreventNativeCaretPlacement =
- event.button === 0 && document.activeElement !== inputElement;
- if (shouldPreventNativeCaretPlacement) {
- event.preventDefault();
- inputElement.focus();
- inputElement.select();
- }
- onMouseDown?.(event);
- }}
- onFocus={(event) => {
- setIsInputFocused(true);
- event.currentTarget.select();
- onFocus?.(event);
- }}
- onKeyDown={(event) => {
- const shouldBlurInput =
- event.key === "Enter" || event.key === "Escape";
- if (shouldBlurInput) event.currentTarget.blur();
- onKeyDown?.(event);
- }}
- onBlur={(event) => {
- setIsInputFocused(false);
- onBlur?.(event);
- }}
- {...props}
- />
+ >
+ {inputNode}
+ {suffix && (
+ <>
+ {/* Ghost mirrors value text to measure width for suffix positioning */}
+ <span
+ ref={ghostRef}
+ className="invisible absolute text-sm leading-none whitespace-pre pointer-events-none"
+ aria-hidden="true"
+ >
+ {value}
+ </span>
+ <span
+ className={cn(
+ "absolute top-1/2 -translate-y-1/2 select-none pointer-events-none text-sm leading-none",
+ suffixClassName,
+ )}
+ style={{ left: suffixLeft + SUFFIX_GAP_PX }}
+ >
+ {suffix}
+ </span>
+ </>
+ )}
+ </span>
{onReset && !isDefault && (
<div className="shrink-0 pr-2 flex items-center">
<Button
@@ -161,7 +294,7 @@ function NumberField({
aria-label="Reset to default"
onClick={onReset}
>
- <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="!size-3.5" />
+ <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="size-3.5!" />
</Button>
</div>
)}
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index 8fd511a3..cf480c8c 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -3,8 +3,26 @@
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Popover = PopoverPrimitive.Root;
+function Popover({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "popover",
+ open,
+ onOpenChange,
+ });
+ return (
+ <PopoverPrimitive.Root
+ open={open}
+ onOpenChange={handleOpenChange}
+ {...props}
+ />
+ );
+}
const PopoverTrigger = PopoverPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 1b57ea4d..b7092b87 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -7,8 +7,22 @@ import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Select = SelectPrimitive.Root;
+function Select({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "select",
+ open,
+ onOpenChange,
+ });
+ return (
+ <SelectPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
const SelectGroup = SelectPrimitive.Group;
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index 3155787d..d56cd0ac 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -4,10 +4,23 @@ import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
-
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Sheet = SheetPrimitive.Root;
+function Sheet({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "sheet",
+ open,
+ onOpenChange,
+ });
+ return (
+ <SheetPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
const SheetTrigger = SheetPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx
index 4bae2fad..f0c8197a 100644
--- a/apps/web/src/components/ui/sonner.tsx
+++ b/apps/web/src/components/ui/sonner.tsx
@@ -12,7 +12,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
- position="top-center"
+ position="bottom-right"
offset={20}
toastOptions={{
classNames: {
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx
index d2844b63..c5cc02d7 100644
--- a/apps/web/src/components/ui/tabs.tsx
+++ b/apps/web/src/components/ui/tabs.tsx
@@ -5,51 +5,81 @@ import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/utils/ui";
-const Tabs = TabsPrimitive.Root;
+type TabsVariant = "default" | "underline";
+
+const TabsVariantContext = React.createContext<TabsVariant>("default");
+
+const Tabs = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & {
+ variant?: TabsVariant;
+ }
+>(({ variant = "default", ...props }, ref) => (
+ <TabsVariantContext.Provider value={variant}>
+ <TabsPrimitive.Root ref={ref} {...props} />
+ </TabsVariantContext.Provider>
+));
+Tabs.displayName = TabsPrimitive.Root.displayName;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
->(({ className, ...props }, ref) => (
- <TabsPrimitive.List
- ref={ref}
- className={cn(
- "text-muted-foreground inline-flex h-auto items-center justify-center gap-2 rounded-lg bg-transparent p-0",
- className,
- )}
- {...props}
- />
-));
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.List
+ ref={ref}
+ className={cn(
+ "text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0",
+ variant === "default" && "rounded-lg",
+ variant === "underline" && "border-b border-border w-full gap-0 px-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
->(({ className, ...props }, ref) => (
- <TabsPrimitive.Trigger
- ref={ref}
- className={cn(
- "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
- className,
- )}
- {...props}
- />
-));
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent",
+ variant === "default" &&
+ "data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
+ variant === "underline" &&
+ "text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
->(({ className, ...props }, ref) => (
- <TabsPrimitive.Content
- ref={ref}
- className={cn(
- "ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
- className,
- )}
- {...props}
- />
-));
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.Content
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
+ variant === "underline" && "px-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index 067042e6..2e7b53d4 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -11,7 +11,7 @@ const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const tooltipVariants = cva(
- "z-50 overflow-visible rounded-sm text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ "z-50 overflow-visible rounded-sm text-sm shadow-md",
{
variants: {
variant: {
@@ -22,7 +22,7 @@ const tooltipVariants = cva(
important:
"bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]",
promotions:
- "bg-red-100/90 text-red-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]",
+ "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]",
personal:
"bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]",
updates:
diff --git a/apps/web/src/components/ui/use-overlay-open-change.ts b/apps/web/src/components/ui/use-overlay-open-change.ts
new file mode 100644
index 00000000..165efc79
--- /dev/null
+++ b/apps/web/src/components/ui/use-overlay-open-change.ts
@@ -0,0 +1,78 @@
+import { useCallback, useEffect, useId, useRef } from "react";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+
+export function useOverlayOpenChange({
+ source,
+ open,
+ onOpenChange,
+}: {
+ source: string;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const { openOverlay, closeOverlay } = useKeybindingsStore();
+ const isTrackedRef = useRef(false);
+ const isControlled = typeof open === "boolean";
+ const overlayId = useId();
+
+ useEffect(() => {
+ if (!isControlled) return;
+
+ if (open && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ return;
+ }
+
+ if (!open && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]);
+
+ useEffect(() => {
+ return () => {
+ if (!isTrackedRef.current) return;
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "post-fix",
+ hypothesisId: "H2",
+ location: "use-overlay-open-change.ts:cleanup",
+ message: "Overlay closed during unmount cleanup",
+ data: { source, overlayId },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ };
+ }, [closeOverlay, overlayId, source]);
+
+ return useCallback(
+ (nextOpen: boolean) => {
+ if (!isControlled) {
+ if (nextOpen && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ } else if (!nextOpen && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }
+
+ onOpenChange?.(nextOpen);
+ },
+ [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source],
+ );
+}