summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-02-23 03:24:02 +0100
committerGitHub <noreply@github.com>2026-02-23 03:24:02 +0100
commit93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch)
tree1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/components/editor
parentfca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff)
downloadopencut-93d1e3383ce47e45157d70f9ba6251239dae6bc2.tar.gz
feat: major editor overhaul (assets, properties, timeline, fonts) (#709)
* feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env
Diffstat (limited to 'apps/web/src/components/editor')
-rw-r--r--apps/web/src/components/editor/editor-header.tsx20
-rw-r--r--apps/web/src/components/editor/export-button.tsx147
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx3
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx (renamed from apps/web/src/components/editor/panels/assets/views/media.tsx)366
-rw-r--r--apps/web/src/components/editor/panels/assets/views/base-view.tsx52
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx9
-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.tsx354
-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.tsx689
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx6
-rw-r--r--apps/web/src/components/editor/panels/panel-base-view.tsx85
-rw-r--r--apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx59
-rw-r--r--apps/web/src/components/editor/panels/preview/context-menu.tsx35
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx181
-rw-r--r--apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx (renamed from apps/web/src/components/editor/layout-guide-overlay.tsx)54
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx52
-rw-r--r--apps/web/src/components/editor/panels/preview/snap-guides.tsx65
-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.tsx80
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx283
-rw-r--r--apps/web/src/components/editor/panels/properties/empty-view.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts72
-rw-r--r--apps/web/src/components/editor/panels/properties/property-item.tsx132
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx161
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx196
-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.tsx248
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx806
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx409
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx29
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx2
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx25
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx12
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx5
37 files changed, 3041 insertions, 2089 deletions
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index e70bda37..2dea73e2 100644
--- a/apps/web/src/components/editor/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -19,7 +19,7 @@ import { ThemeToggle } from "../theme-toggle";
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
-import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons";
+import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
import Image from "next/image";
@@ -118,32 +118,30 @@ function ProjectDropdown() {
/>
</Button>
</DropdownMenuTrigger>
- <DropdownMenuContent align="start" className="z-100 w-52">
+ <DropdownMenuContent align="start" className="z-100 w-44">
<DropdownMenuItem
- className="flex items-center gap-1.5"
onClick={handleExit}
disabled={isExiting}
+ icon={<HugeiconsIcon icon={Logout05Icon} />}
>
- <HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Exit project
</DropdownMenuItem>
- <DropdownMenuSeparator />
<DropdownMenuItem
- className="flex items-center gap-1.5"
onClick={() => setOpenDialog("shortcuts")}
+ icon={<HugeiconsIcon icon={CommandIcon} />}
>
- <HugeiconsIcon icon={CommandIcon} className="size-4" />
- Keyboard shortcuts
+ Shortcuts
</DropdownMenuItem>
- <DropdownMenuItem asChild>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}>
<Link
href={SOCIAL_LINKS.discord}
target="_blank"
rel="noopener noreferrer"
- className="flex items-center gap-1.5"
>
- <FaDiscord className="size-4" />
Discord
</Link>
</DropdownMenuItem>
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index f39c18e2..bc44e650 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -23,7 +23,11 @@ import {
type ExportQuality,
type ExportResult,
} from "@/types/export";
-import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+} from "@/components/editor/panels/properties/section";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
@@ -162,78 +166,83 @@ function ExportPopover({
{!isExporting && (
<>
<div className="flex flex-col">
- <PropertyGroup
- title="Format"
- defaultExpanded={false}
- hasBorderTop={false}
- >
- <RadioGroup
- value={format}
- onValueChange={(value) => {
- if (isExportFormat(value)) {
- setFormat(value);
- }
- }}
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="mp4" id="mp4" />
- <Label htmlFor="mp4">
- MP4 (H.264) - Better compatibility
- </Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="webm" id="webm" />
- <Label htmlFor="webm">
- WebM (VP9) - Smaller file size
- </Label>
- </div>
- </RadioGroup>
- </PropertyGroup>
+ <Section collapsible defaultOpen={false} hasBorderTop={false}>
+ <SectionHeader title="Format" />
+ <SectionContent>
+ <RadioGroup
+ value={format}
+ onValueChange={(value) => {
+ if (isExportFormat(value)) {
+ setFormat(value);
+ }
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="mp4" id="mp4" />
+ <Label htmlFor="mp4">
+ MP4 (H.264) - Better compatibility
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="webm" id="webm" />
+ <Label htmlFor="webm">
+ WebM (VP9) - Smaller file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </SectionContent>
+ </Section>
- <PropertyGroup title="Quality" defaultExpanded={false}>
- <RadioGroup
- value={quality}
- onValueChange={(value) => {
- if (isExportQuality(value)) {
- setQuality(value);
- }
- }}
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="low" id="low" />
- <Label htmlFor="low">Low - Smallest file size</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="medium" id="medium" />
- <Label htmlFor="medium">Medium - Balanced</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="high" id="high" />
- <Label htmlFor="high">High - Recommended</Label>
- </div>
+ <Section collapsible defaultOpen={false}>
+ <SectionHeader title="Quality" />
+ <SectionContent>
+ <RadioGroup
+ value={quality}
+ onValueChange={(value) => {
+ if (isExportQuality(value)) {
+ setQuality(value);
+ }
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="low" id="low" />
+ <Label htmlFor="low">Low - Smallest file size</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="medium" id="medium" />
+ <Label htmlFor="medium">Medium - Balanced</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="high" id="high" />
+ <Label htmlFor="high">High - Recommended</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="very_high" id="very_high" />
+ <Label htmlFor="very_high">
+ Very High - Largest file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </SectionContent>
+ </Section>
+
+ <Section collapsible defaultOpen={false}>
+ <SectionHeader title="Audio" />
+ <SectionContent>
<div className="flex items-center space-x-2">
- <RadioGroupItem value="very_high" id="very_high" />
- <Label htmlFor="very_high">
- Very High - Largest file size
+ <Checkbox
+ id="include-audio"
+ checked={includeAudio}
+ onCheckedChange={(checked) =>
+ setIncludeAudio(!!checked)
+ }
+ />
+ <Label htmlFor="include-audio">
+ Include audio in export
</Label>
</div>
- </RadioGroup>
- </PropertyGroup>
-
- <PropertyGroup title="Audio" defaultExpanded={false}>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="include-audio"
- checked={includeAudio}
- onCheckedChange={(checked) =>
- setIncludeAudio(!!checked)
- }
- />
- <Label htmlFor="include-audio">
- Include audio in export
- </Label>
- </div>
- </PropertyGroup>
+ </SectionContent>
+ </Section>
</div>
<div className="p-3 pt-0">
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 517fdc92..be4176ce 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -163,7 +163,9 @@ export function DraggableItem({
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
{preview}
</div>
- <span className="w-full flex-1 truncate text-sm text-left">{name}</span>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
</button>
</div>
)}
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
index e7bd3d4f..3600d00b 100644
--- a/apps/web/src/components/editor/panels/assets/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator";
import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
import { TabBar } from "./tabbar";
import { Captions } from "./views/captions";
-import { MediaView } from "./views/media";
+import { MediaView } from "./views/assets";
import { SettingsView } from "./views/settings";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index 31842306..61777a7c 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -61,7 +61,8 @@ export function TabBar() {
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",
+ activeTab !== tabKey &&
+ "border border-transparent text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
>
diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 59395fd8..808961f1 100644
--- a/apps/web/src/components/editor/panels/assets/views/media.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -3,6 +3,7 @@
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 { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
@@ -29,14 +30,9 @@ import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media/processing";
-import {
- buildImageElement,
- buildUploadAudioElement,
- buildVideoElement,
-} from "@/lib/timeline/element-utils";
+import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import type { MediaAsset } from "@/types/assets";
-import type { CreateTimelineElement } from "@/types/timeline";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
@@ -132,7 +128,15 @@ export function MediaView() {
asset: MediaAsset;
startTime: number;
}): boolean => {
- const element = createElementFromMedia({ asset, startTime });
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const element = buildElementFromMedia({
+ mediaId: asset.id,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ });
editor.timeline.insertElement({
element,
placement: { mode: "auto" },
@@ -194,171 +198,166 @@ export function MediaView() {
const renderCompactPreview = (item: MediaAsset) =>
previewComponents.get(`compact-${item.id}`);
- return (
- <>
- <input {...fileInputProps} />
-
- <div
- className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
- {...dragProps}
- >
- <div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b">
- <span className="text-muted-foreground text-sm">Assets</span>
- <div className="flex items-center gap-0">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
+ const mediaActions = (
+ <div>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ size="icon"
+ variant="ghost"
+ onClick={() =>
+ setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
+ }
+ disabled={isProcessing}
+ className="items-center justify-center"
+ >
+ {mediaViewMode === "grid" ? (
+ <HugeiconsIcon icon={LeftToRightListDashIcon} />
+ ) : (
+ <HugeiconsIcon icon={GridViewIcon} />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>
+ {mediaViewMode === "grid"
+ ? "Switch to list view"
+ : "Switch to grid view"}
+ </p>
+ </TooltipContent>
+ <Tooltip>
+ <DropdownMenu>
+ <TooltipTrigger asChild>
+ <DropdownMenuTrigger asChild>
<Button
size="icon"
- variant="text"
- onClick={() =>
- setMediaViewMode(
- mediaViewMode === "grid" ? "list" : "grid",
- )
- }
+ variant="ghost"
disabled={isProcessing}
className="items-center justify-center"
>
- {mediaViewMode === "grid" ? (
- <HugeiconsIcon icon={LeftToRightListDashIcon} />
- ) : (
- <HugeiconsIcon icon={GridViewIcon} />
- )}
+ <HugeiconsIcon icon={SortingOneNineIcon} />
</Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>
- {mediaViewMode === "grid"
- ? "Switch to list view"
- : "Switch to grid view"}
- </p>
- </TooltipContent>
- <Tooltip>
- <DropdownMenu>
- <TooltipTrigger asChild>
- <DropdownMenuTrigger asChild>
- <Button
- size="icon"
- variant="text"
- disabled={isProcessing}
- className="items-center justify-center"
- >
- <HugeiconsIcon icon={SortingOneNineIcon} />
- </Button>
- </DropdownMenuTrigger>
- </TooltipTrigger>
- <DropdownMenuContent align="end">
- <SortMenuItem
- label="Name"
- sortKey="name"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="Type"
- sortKey="type"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="Duration"
- sortKey="duration"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="File size"
- sortKey="size"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- </DropdownMenuContent>
- </DropdownMenu>
- <TooltipContent>
- <p>
- Sort by {sortBy} (
- {sortOrder === "asc" ? "ascending" : "descending"})
- </p>
- </TooltipContent>
- </Tooltip>
- </Tooltip>
- </TooltipProvider>
- <Button
- variant="outline"
- onClick={openFilePicker}
- disabled={isProcessing}
- size="sm"
- className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3"
- >
- <HugeiconsIcon icon={CloudUploadIcon} />
- Import
- </Button>
- </div>
- </div>
+ </DropdownMenuTrigger>
+ </TooltipTrigger>
+ <DropdownMenuContent align="end">
+ <SortMenuItem
+ label="Name"
+ sortKey="name"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="Type"
+ sortKey="type"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="Duration"
+ sortKey="duration"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="File size"
+ sortKey="size"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <TooltipContent>
+ <p>
+ Sort by {sortBy} (
+ {sortOrder === "asc" ? "ascending" : "descending"})
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </Tooltip>
+ </TooltipProvider>
+ <Button
+ variant="outline"
+ onClick={openFilePicker}
+ disabled={isProcessing}
+ size="sm"
+ className="items-center justify-center gap-1.5 ml-1.5"
+ >
+ <HugeiconsIcon icon={CloudUploadIcon} />
+ Import
+ </Button>
+ </div>
+ );
- <div className="scrollbar-thin size-full overflow-y-auto pt-1">
- <div className="w-full flex-1 p-3 pt-0">
- {isDragOver || filteredMediaItems.length === 0 ? (
- <MediaDragOverlay
- isVisible={true}
- isProcessing={isProcessing}
- progress={progress}
- onClick={openFilePicker}
- />
- ) : mediaViewMode === "grid" ? (
- <GridView
- items={filteredMediaItems}
- renderPreview={renderPreview}
- onRemove={handleRemove}
- onAddToTimeline={addElementAtTime}
- highlightedId={highlightedId}
- registerElement={registerElement}
- />
- ) : (
- <ListView
- items={filteredMediaItems}
- renderPreview={renderCompactPreview}
- onRemove={handleRemove}
- onAddToTimeline={addElementAtTime}
- highlightedId={highlightedId}
- registerElement={registerElement}
- />
- )}
- </div>
- </div>
- </div>
+ return (
+ <>
+ <input {...fileInputProps} />
+
+ <PanelView
+ title="Assets"
+ actions={mediaActions}
+ className={isDragOver ? "bg-accent/30" : ""}
+ {...dragProps}
+ >
+ {isDragOver || filteredMediaItems.length === 0 ? (
+ <MediaDragOverlay
+ isVisible={true}
+ isProcessing={isProcessing}
+ progress={progress}
+ onClick={openFilePicker}
+ />
+ ) : mediaViewMode === "grid" ? (
+ <GridView
+ items={filteredMediaItems}
+ renderPreview={renderPreview}
+ onRemove={handleRemove}
+ onAddToTimeline={addElementAtTime}
+ highlightedId={highlightedId}
+ registerElement={registerElement}
+ />
+ ) : (
+ <ListView
+ items={filteredMediaItems}
+ renderPreview={renderCompactPreview}
+ onRemove={handleRemove}
+ onAddToTimeline={addElementAtTime}
+ highlightedId={highlightedId}
+ registerElement={registerElement}
+ />
+ )}
+ </PanelView>
</>
);
}
@@ -636,40 +635,3 @@ function SortMenuItem({
</DropdownMenuItem>
);
}
-
-function createElementFromMedia({
- asset,
- startTime,
-}: {
- asset: MediaAsset;
- startTime: number;
-}): CreateTimelineElement {
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
-
- switch (asset.type) {
- case "video":
- return buildVideoElement({
- mediaId: asset.id,
- name: asset.name,
- duration,
- startTime,
- });
- case "image":
- return buildImageElement({
- mediaId: asset.id,
- name: asset.name,
- duration,
- startTime,
- });
- case "audio":
- return buildUploadAudioElement({
- mediaId: asset.id,
- name: asset.name,
- duration,
- startTime,
- });
- default:
- throw new Error(`Unsupported media type: ${asset.type}`);
- }
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-view.tsx
new file mode 100644
index 00000000..c613bc2a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/base-view.tsx
@@ -0,0 +1,52 @@
+import { cn } from "@/utils/ui";
+
+interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
+ title?: string;
+ actions?: React.ReactNode;
+ children: React.ReactNode;
+ contentClassName?: string;
+ hideHeader?: boolean;
+ ref?: React.Ref<HTMLDivElement>;
+ onScroll?: React.UIEventHandler<HTMLDivElement>;
+ scrollRef?: React.Ref<HTMLDivElement>;
+}
+
+export function PanelView({
+ title,
+ actions,
+ children,
+ className,
+ contentClassName,
+ hideHeader = false,
+ ref,
+ onScroll,
+ scrollRef,
+ ...rest
+}: PanelViewProps) {
+ return (
+ <div
+ className={cn("relative flex h-full flex-col", className)}
+ ref={ref}
+ {...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>
+ {actions}
+ </div>
+ )}
+ <div
+ className={cn(
+ "scrollbar-thin size-full overflow-y-auto",
+ hideHeader ? "pt-4" : "pt-2",
+ )}
+ ref={scrollRef}
+ onScroll={onScroll}
+ >
+ <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
+ {children}
+ </div>
+ </div>
+ </div>
+ );
+}
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 daebf2c6..7a3b16bf 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 { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
@@ -108,10 +108,7 @@ export function Captions() {
};
return (
- <BaseView
- ref={containerRef}
- className="flex h-full flex-col justify-between"
- >
+ <PanelView title="Captions" ref={containerRef}>
<div className="flex flex-col gap-3">
<Label>Language</Label>
<Select
@@ -148,6 +145,6 @@ export function Captions() {
{isProcessing ? processingStep : "Generate transcript"}
</Button>
</div>
- </BaseView>
+ </PanelView>
);
}
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
new file mode 100644
index 00000000..457c08a9
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
@@ -0,0 +1,332 @@
+// @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
index 26afe566..7e10ac9d 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx
@@ -1,8 +1,6 @@
"use client";
-import Image from "next/image";
-import { memo, useCallback, useMemo } from "react";
-import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
@@ -10,63 +8,54 @@ import {
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 { FPS_PRESETS } from "@/constants/project-constants";
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/property-item";
-import { ColorPicker } from "@/components/ui/color-picker";
+ Section,
+ SectionContent,
+ SectionHeader,
+} from "@/components/editor/panels/properties/section";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { useState } from "react";
export function SettingsView() {
- return <ProjectSettingsTabs />;
-}
+ const [open, setOpen] = useState(false);
-function ProjectSettingsTabs() {
return (
- <BaseView
- defaultTab="project-info"
- tabs={[
- {
- value: "project-info",
- label: "Project info",
- content: (
- <div className="p-5">
- <ProjectInfoView />
- </div>
- ),
- },
- {
- value: "background",
- label: "Background",
- content: (
- <div className="flex h-full flex-col justify-between">
- <div className="flex-1">
- <BackgroundView />
+ <PanelView contentClassName="px-0" hideHeader>
+ <div className="flex flex-col">
+ <Section hasBorderTop={false}>
+ <SectionContent>
+ <ProjectInfoContent />
+ </SectionContent>
+ </Section>
+ <Popover open={open} onOpenChange={setOpen}>
+ <Section className="cursor-pointer">
+ <PopoverTrigger asChild>
+ <div>
+ <SectionHeader title="Background">
+ <div className="size-4 rounded-sm bg-red-500" />
+ </SectionHeader>
</div>
- </div>
- ),
- },
- ]}
- className="flex h-full flex-col justify-between p-0"
- />
+ </PopoverTrigger>
+ </Section>
+ <PopoverContent>
+ <div className="size-4 rounded-sm bg-red-500" />
+ </PopoverContent>
+ </Popover>
+ </div>
+ </PanelView>
);
}
-function ProjectInfoView() {
+function ProjectInfoContent() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore();
@@ -124,232 +113,55 @@ function ProjectInfoView() {
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}
+ <div className="flex flex-col gap-2">
+ <Label>Name</Label>
+ <span className="leading-none text-sm">
+ {activeProject.metadata.name}
</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 h-full 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-col gap-2">
+ <Label>Aspect ratio</Label>
+ <Select
+ value={selectedPresetValue}
+ onValueChange={(value) => handleAspectRatioChange({ value })}
>
- <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>
- ))}
+ <SelectTrigger className="w-fit">
+ <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>
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label>Frame rate</Label>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={handleFpsChange}
+ >
+ <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/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
index 30c70a83..b1c57c3d 100644
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -235,7 +235,7 @@ function SoundEffectsView() {
<div className="flex items-center gap-3">
<Input
placeholder="Search sound effects"
- className="bg-accent w-full"
+ className="w-full"
containerClassName="w-full"
value={searchQuery}
onChange={({ currentTarget }) =>
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 68ba91d2..671effec 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -4,144 +4,104 @@ import Image from "next/image";
import type { CSSProperties } from "react";
import { useEffect, useMemo, 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 { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
import { Button } from "@/components/ui/button";
-import { InputWithBack } from "@/components/ui/input-with-back";
-import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
-import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import {
- buildIconSvgUrl,
- getIconSvgUrl,
- ICONIFY_HOSTS,
- POPULAR_COLLECTIONS,
-} from "@/lib/iconify-api";
+ resolveStickerId,
+ type StickerItem as StickerData,
+} from "@/lib/stickers";
import { useStickersStore } from "@/stores/stickers-store";
-import type { StickerCategory } from "@/types/stickers";
import { cn } from "@/utils/ui";
import {
- ArrowRightIcon,
HappyIcon,
ClockIcon,
- LayoutGridIcon,
MultiplicationSignIcon,
- SparklesIcon,
- HashtagIcon,
+ Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
-
-function isStickerCategory(value: string): value is StickerCategory {
- return STICKER_CATEGORIES.includes(value as StickerCategory);
-}
+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();
return (
- <BaseView
- value={selectedCategory}
- onValueChange={(v) => {
- if (isStickerCategory(v)) {
- setSelectedCategory({ category: v });
- }
- }}
- tabs={[
- {
- value: "all",
- label: "All",
- icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />,
- content: <StickersContentView category="all" />,
- },
- {
- value: "general",
- label: "Icons",
- icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />,
- content: <StickersContentView category="general" />,
- },
- {
- value: "brands",
- label: "Brands",
- icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />,
- content: <StickersContentView category="brands" />,
- },
- {
- value: "emoji",
- label: "Emoji",
- icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />,
- content: <StickersContentView category="emoji" />,
- },
- ]}
- className="flex h-full flex-col overflow-hidden p-0"
- />
+ <PanelView
+ title="Stickers"
+ actions={
+ <div className="flex items-center">
+ <Select
+ value={selectedCategory}
+ onValueChange={(value: StickerCategory) =>
+ setSelectedCategory({ category: value })
+ }
+ >
+ <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}>
+ {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>
+ </div>
+ }
+ >
+ <StickersContentView />
+ </PanelView>
);
}
function StickerGrid({
- icons,
- onAdd,
- addingSticker,
- capSize = false,
+ items,
+ shouldCapSize = false,
}: {
- icons: string[];
- onAdd: (iconName: string) => void;
- addingSticker: string | null;
- capSize?: boolean;
+ items: StickerData[];
+ shouldCapSize?: boolean;
}) {
const gridStyle: CSSProperties & {
"--sticker-min": string;
"--sticker-max"?: string;
} = {
- gridTemplateColumns: capSize
+ 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",
- ...(capSize ? { "--sticker-max": "160px" } : {}),
+ ...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
};
return (
<div className="grid gap-2" style={gridStyle}>
- {icons.map((iconName) => (
- <StickerItem
- key={iconName}
- iconName={iconName}
- onAdd={onAdd}
- isAdding={addingSticker === iconName}
- capSize={capSize}
- />
- ))}
- </div>
- );
-}
-
-function CollectionGrid({
- collections,
- onSelectCollection,
-}: {
- collections: Array<{
- prefix: string;
- name: string;
- total: number;
- category?: string;
- }>;
- onSelectCollection: ({ prefix }: { prefix: string }) => void;
-}) {
- return (
- <div className="grid grid-cols-1 gap-2">
- {collections.map((collection) => (
- <CollectionItem
- key={collection.prefix}
- title={collection.name}
- subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
- onClick={() => onSelectCollection({ prefix: collection.prefix })}
- />
+ {items.map((item) => (
+ <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
))}
</div>
);
@@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) {
);
}
-function StickersContentView({ category }: { category: StickerCategory }) {
+function StickersContentView() {
const {
searchQuery,
- selectedCollection,
viewMode,
- collections,
- currentCollection,
searchResults,
recentStickers,
- isLoadingCollections,
- isLoadingCollection,
isSearching,
- setSearchQuery,
- setSelectedCollection,
- loadCollections,
- searchStickers,
- addStickerToTimeline,
clearRecentStickers,
- setSelectedCategory,
- addingSticker,
} = useStickersStore();
- const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
- const [collectionsToShow, setCollectionsToShow] = useState(20);
- const [showCollectionItems, setShowCollectionItems] = useState(false);
-
- const filteredCollections = useMemo(() => {
- if (category === "all") {
- return Object.entries(collections).map(([prefix, collection]) => ({
- prefix,
- name: collection.name,
- total: collection.total,
- category: collection.category,
- }));
- }
-
- const collectionList =
- POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
- if (!collectionList) return [];
-
- return collectionList
- .map((c) => {
- const collection = collections[c.prefix];
- return collection
- ? {
- prefix: c.prefix,
- name: c.name,
- total: collection.total,
- }
- : null;
- })
- .filter(Boolean) as Array<{
- prefix: string;
- name: string;
- total: number;
- }>;
- }, [collections, category]);
-
- const { scrollAreaRef, handleScroll } = useInfiniteScroll({
- onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
- hasMore: filteredCollections.length > collectionsToShow,
- isLoading: isLoadingCollections,
- enabled: viewMode === "browse" && !selectedCollection && category === "all",
- });
-
- useEffect(() => {
- if (Object.keys(collections).length === 0) {
- loadCollections();
- }
- }, [collections, loadCollections]);
-
- useEffect(() => {
- const timer = setTimeout(() => {
- if (localSearchQuery !== searchQuery) {
- setSearchQuery({ query: localSearchQuery });
- if (localSearchQuery.trim()) {
- searchStickers({ query: localSearchQuery });
- }
- }
- }, 500);
-
- return () => clearTimeout(timer);
- }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]);
-
- const handleAddSticker = async (iconName: string) => {
- try {
- await addStickerToTimeline({ iconName });
- } catch (error) {
- console.error("Failed to add sticker:", error);
- toast.error("Failed to add sticker to timeline");
- }
- };
-
- const iconsToDisplay = useMemo(() => {
+ const itemsToDisplay = useMemo(() => {
if (viewMode === "search" && searchResults) {
- return searchResults.icons;
- }
-
- if (viewMode === "collection" && currentCollection) {
- const icons: string[] = [];
-
- if (currentCollection.uncategorized) {
- icons.push(
- ...currentCollection.uncategorized.map(
- (name) => `${currentCollection.prefix}:${name}`,
- ),
- );
- }
-
- if (currentCollection.categories) {
- Object.values(currentCollection.categories).forEach((categoryIcons) => {
- icons.push(
- ...categoryIcons.map(
- (name) => `${currentCollection.prefix}:${name}`,
- ),
- );
- });
- }
-
- return icons.slice(0, 100);
+ return searchResults.items;
}
return [];
- }, [viewMode, searchResults, currentCollection]);
+ }, [viewMode, searchResults]);
- const isInCollection = viewMode === "collection" && !!selectedCollection;
-
- useEffect(() => {
- if (isInCollection) {
- setShowCollectionItems(false);
- const timer = setTimeout(() => setShowCollectionItems(true), 350);
- return () => clearTimeout(timer);
- } else {
- setShowCollectionItems(false);
+ const recentStickerItems = useMemo(() => {
+ const items: StickerData[] = [];
+ for (const stickerId of recentStickers) {
+ const recentStickerItem = toRecentStickerItem({ stickerId });
+ if (recentStickerItem) {
+ items.push(recentStickerItem);
+ }
}
- }, [isInCollection]);
+ return items;
+ }, [recentStickers]);
return (
- <div className="mt-1 flex h-full flex-col gap-5 p-4">
- <div className="space-y-3">
- <InputWithBack
- isExpanded={isInCollection}
- setIsExpanded={(expanded) => {
- if (!expanded && isInCollection) {
- setSelectedCollection({ collection: null });
- }
- }}
- placeholder={
- category === "all"
- ? "Search all stickers"
- : category === "general"
- ? "Search icons"
- : category === "brands"
- ? "Search brands"
- : "Search Emojis"
- }
- value={localSearchQuery}
- onChange={setLocalSearchQuery}
- disableAnimation={true}
- />
- </div>
-
- <div className="relative h-full overflow-hidden">
- <ScrollArea
- className="h-full flex-1"
- ref={scrollAreaRef}
- onScrollCapture={handleScroll}
- >
- <div className="flex h-full flex-col gap-4">
- {recentStickers.length > 0 && viewMode === "browse" && (
- <div className="h-full">
- <div className="mb-2 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>
- <StickerGrid
- icons={recentStickers.slice(0, 12)}
- onAdd={handleAddSticker}
- addingSticker={addingSticker}
- capSize
- />
- </div>
- )}
-
- {viewMode === "collection" && selectedCollection && (
- <div className="h-full">
- {isLoadingCollection ? (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- ) : showCollectionItems ? (
- <StickerGrid
- icons={iconsToDisplay}
- onAdd={handleAddSticker}
- addingSticker={addingSticker}
- />
- ) : (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- )}
- </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?.icons.length ? (
- <>
- <div className="mb-3 flex items-center justify-between">
- <span className="text-muted-foreground text-sm">
- {searchResults.total} results
- </span>
- </div>
- <StickerGrid
- icons={iconsToDisplay}
- onAdd={handleAddSticker}
- addingSticker={addingSticker}
- capSize
+ <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"
/>
- </>
- ) : searchQuery ? (
- <div className="flex flex-col items-center justify-center gap-3 py-8">
- <EmptyView
- message={`No stickers found for "${searchQuery}"`}
- />
- {category !== "all" && (
- <Button
- variant="outline"
- onClick={() => {
- const q = localSearchQuery || searchQuery;
- if (q) {
- setSearchQuery({ query: q });
- }
- setSelectedCategory({ category: "all" });
- if (q) {
- searchStickers({ query: q });
- }
- }}
- >
- Search in all icons
- </Button>
- )}
- </div>
- ) : null}
- </div>
- )}
-
- {viewMode === "browse" && !selectedCollection && (
- <div className="h-full space-y-4">
- {isLoadingCollections ? (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- ) : (
- <>
- {category !== "all" && (
- <div className="h-full">
- <CollectionGrid
- collections={filteredCollections}
- onSelectCollection={({ prefix }) =>
- setSelectedCollection({ collection: prefix })
- }
- />
- </div>
- )}
+ </button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>Clear recent stickers</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
+ </div>
+ )}
- {category === "all" && filteredCollections.length > 0 && (
- <div className="h-full">
- <CollectionGrid
- collections={filteredCollections.slice(
- 0,
- collectionsToShow,
- )}
- onSelectCollection={({ prefix }) =>
- setSelectedCollection({ collection: prefix })
- }
- />
- </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>
- )}
- </div>
- </ScrollArea>
- </div>
+ <StickerGrid items={itemsToDisplay} shouldCapSize />
+ </div>
+ ) : searchQuery ? (
+ <EmptyView message={`No stickers found for "${searchQuery}"`} />
+ ) : null}
+ </div>
+ )}
</div>
);
}
-interface CollectionItemProps {
- title: string;
- subtitle: string;
- onClick: () => void;
-}
-
-function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
- return (
- <Button
- variant="outline"
- className="h-auto justify-between rounded-md py-2"
- onClick={onClick}
- >
- <div className="text-left">
- <p className="font-medium">{title}</p>
- <p className="text-muted-foreground text-xs">{subtitle}</p>
- </div>
- <HugeiconsIcon icon={ArrowRightIcon} className="size-4" />
- </Button>
- );
-}
-
interface StickerItemProps {
- iconName: string;
- onAdd: (iconName: string) => void;
- isAdding?: boolean;
- capSize?: boolean;
+ item: StickerData;
+ shouldCapSize?: boolean;
}
-function StickerItem({
- iconName,
- onAdd,
- isAdding,
- capSize = false,
-}: StickerItemProps) {
- const [imageError, setImageError] = useState(false);
- const [hostIndex, setHostIndex] = useState(0);
+function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
+ const { addingSticker, addStickerToTimeline } = useStickersStore();
+ const isAdding = addingSticker === item.id;
+ const [hasImageError, setHasImageError] = useState(false);
useEffect(() => {
- if (!iconName) {
+ if (!item.id) {
return;
}
- setImageError(false);
- setHostIndex(0);
- }, [iconName]);
+ setHasImageError(false);
+ }, [item.id]);
- const displayName = iconName.split(":")[1] || iconName;
- const collectionPrefix = iconName.split(":")[0];
+ const displayName = item.name;
+
+ const handleAdd = async () => {
+ try {
+ await addStickerToTimeline({
+ stickerId: item.id,
+ name: item.name,
+ });
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ }
+ };
- const preview = imageError ? (
+ 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}
@@ -536,21 +249,13 @@ function StickerItem({
) : (
<div className="flex size-full items-center justify-center p-4">
<Image
- src={
- hostIndex === 0
- ? getIconSvgUrl(iconName, { width: 64, height: 64 })
- : buildIconSvgUrl(
- ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
- iconName,
- { width: 64, height: 64 },
- )
- }
+ src={item.previewUrl}
alt={displayName}
width={64}
height={64}
className="size-full object-contain"
style={
- capSize
+ shouldCapSize
? {
maxWidth: "var(--sticker-max, 160px)",
maxHeight: "var(--sticker-max, 160px)",
@@ -558,12 +263,7 @@ function StickerItem({
: undefined
}
onError={() => {
- const next = hostIndex + 1;
- if (next < ICONIFY_HOSTS.length) {
- setHostIndex(next);
- } else {
- setImageError(true);
- }
+ setHasImageError(true);
}}
loading="lazy"
unoptimized
@@ -572,44 +272,63 @@ function StickerItem({
);
return (
- <Tooltip>
- <TooltipTrigger asChild>
- <div
- className={cn(
- "relative",
- isAdding && "pointer-events-none opacity-50",
- )}
- >
- <DraggableItem
- name={displayName}
- preview={preview}
- dragData={{
- id: iconName,
- type: "sticker",
- name: displayName,
- iconName,
- }}
- onAddToTimeline={() => onAdd(iconName)}
- aspectRatio={1}
- shouldShowLabel={false}
- isRounded={true}
- variant="card"
- className=""
- containerClassName="w-full"
- />
- {isAdding && (
- <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
- <Spinner className="size-6 text-white" />
- </div>
- )}
- </div>
- </TooltipTrigger>
- <TooltipContent>
- <div className="space-y-1">
- <p className="font-medium">{displayName}</p>
- <p className="text-muted-foreground text-xs">{collectionPrefix}</p>
+ <div
+ className={cn("relative", isAdding && "pointer-events-none opacity-50")}
+ >
+ <DraggableItem
+ name={displayName}
+ preview={preview}
+ dragData={{
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ }}
+ onAddToTimeline={handleAdd}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded
+ variant="card"
+ containerClassName="w-full"
+ />
+ {isAdding && (
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
+ <Spinner className="size-6 text-white" />
</div>
- </TooltipContent>
- </Tooltip>
+ )}
+ </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 6360c363..3673acc3 100644
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -1,5 +1,5 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { buildTextElement } from "@/lib/timeline/element-utils";
@@ -23,7 +23,7 @@ export function TextView() {
};
return (
- <BaseView>
+ <PanelView title="Text">
<DraggableItem
name="Default text"
preview={
@@ -41,6 +41,6 @@ export function TextView() {
onAddToTimeline={handleAddToTimeline}
shouldShowLabel={false}
/>
- </BaseView>
+ </PanelView>
);
}
diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx
deleted file mode 100644
index dd6151d3..00000000
--- a/apps/web/src/components/editor/panels/panel-base-view.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { cn } from "@/utils/ui";
-
-interface PanelBaseViewProps {
- children?: React.ReactNode;
- defaultTab?: string;
- value?: string;
- onValueChange?: (value: string) => void;
- tabs?: {
- value: string;
- label: string;
- icon?: React.ReactNode;
- content: React.ReactNode;
- }[];
- className?: string;
- ref?: React.Ref<HTMLDivElement>;
-}
-
-function ViewContent({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
- <ScrollArea className="flex-1 scrollbar-hidden">
- <div className={cn("p-5", className)}>{children}</div>
- </ScrollArea>
- );
-}
-
-export function PanelBaseView({
- children,
- defaultTab,
- value,
- onValueChange,
- tabs,
- className = "",
- ref,
-}: PanelBaseViewProps) {
- return (
- <div className={cn("flex h-full flex-col", className)} ref={ref}>
- {!tabs || tabs.length === 0 ? (
- <ViewContent className={className}>{children}</ViewContent>
- ) : (
- <Tabs
- defaultValue={defaultTab}
- value={value}
- onValueChange={onValueChange}
- className="flex h-full flex-col"
- >
- <div className="bg-background sticky top-0 z-10">
- <div className="px-3 pt-3 pb-0">
- <TabsList>
- {tabs.map((tab) => (
- <TabsTrigger key={tab.value} value={tab.value}>
- {tab.icon ? (
- <span className="mr-1 inline-flex items-center">
- {tab.icon}
- </span>
- ) : null}
- {tab.label}
- </TabsTrigger>
- ))}
- </TabsList>
- </div>
- <Separator className="mt-3" />
- </div>
- {tabs.map((tab) => (
- <TabsContent
- key={tab.value}
- value={tab.value}
- className="mt-0 flex min-h-0 flex-1 flex-col"
- >
- <ViewContent className={className}>{tab.content}</ViewContent>
- </TabsContent>
- ))}
- </Tabs>
- )}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx
new file mode 100644
index 00000000..4ace25ac
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import { findCurrentScene } from "@/lib/scenes";
+import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
+
+export function BookmarkNoteOverlay() {
+ const editor = useEditor();
+ const currentTime = editor.playback.getCurrentTime();
+ const activeProject = editor.project.getActive();
+
+ if (!activeProject) {
+ return null;
+ }
+
+ const activeScene = findCurrentScene({
+ scenes: activeProject.scenes,
+ currentSceneId: activeProject.currentSceneId,
+ });
+
+ if (!activeScene) {
+ return null;
+ }
+
+ const bookmarks = activeScene.bookmarks;
+ const activeBookmarks = getBookmarksActiveAtTime({
+ bookmarks,
+ time: currentTime,
+ });
+ const bookmarksWithNotes = activeBookmarks.filter(
+ (bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
+ );
+
+ if (bookmarksWithNotes.length === 0) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
+ aria-live="polite"
+ >
+ {bookmarksWithNotes.map((bookmark) => (
+ <div
+ key={bookmark.time}
+ className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
+ style={{
+ backgroundColor: "rgb(0 0 0 / 0.5)",
+ borderLeft: bookmark.color
+ ? `3px solid ${bookmark.color}`
+ : "3px solid var(--primary)",
+ }}
+ >
+ {bookmark.note}
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx
new file mode 100644
index 00000000..d5cf535a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import {
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuItem,
+} from "@/components/ui/context-menu";
+import { usePreviewStore } from "@/stores/preview-store";
+
+export function PreviewContextMenu({
+ onToggleFullscreen,
+ containerRef,
+}: {
+ onToggleFullscreen: () => void;
+ containerRef: React.RefObject<HTMLElement | null>;
+}) {
+ const { overlays, setOverlayVisibility } = usePreviewStore();
+
+ return (
+ <ContextMenuContent className="w-56" container={containerRef.current}>
+ <ContextMenuItem onClick={onToggleFullscreen} inset>
+ Full screen
+ </ContextMenuItem>
+ <ContextMenuCheckboxItem
+ checked={overlays.bookmarks}
+ onCheckedChange={(checked) =>
+ setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
+ }
+ >
+ Show bookmarks
+ </ContextMenuCheckboxItem>
+ <ContextMenuItem inset>Show grid</ContextMenuItem>
+ </ContextMenuContent>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index 902547bd..90cb11a3 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -9,18 +9,13 @@ import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
-import { formatTimeCode, getLastFrameTime } from "@/lib/time";
+import { getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
-import { EditableTimecode } from "@/components/editable-timecode";
-import { invokeAction } from "@/lib/actions";
-import { Button } from "@/components/ui/button";
-import {
- FullScreenIcon,
- PauseIcon,
- PlayIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { cn } from "@/utils/ui";
+import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
+import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
+import { usePreviewStore } from "@/stores/preview-store";
+import { PreviewContextMenu } from "./context-menu";
+import { PreviewToolbar } from "./toolbar";
function usePreviewSize() {
const editor = useEditor();
@@ -32,6 +27,30 @@ function usePreviewSize() {
};
}
+export function PreviewPanel() {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const { isFullscreen, 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}
+ onToggleFullscreen={toggleFullscreen}
+ />
+ </div>
+ );
+}
+
function RenderTreeController() {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
@@ -58,98 +77,24 @@ function RenderTreeController() {
return null;
}
-export function PreviewPanel() {
- const containerRef = useRef<HTMLDivElement>(null);
- const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
-
- return (
- <div
- ref={containerRef}
- className={cn(
- "panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border",
- isFullscreen && "bg-background",
- )}
- >
- <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
- <PreviewCanvas />
- <RenderTreeController />
- </div>
- <PreviewToolbar
- isFullscreen={isFullscreen}
- onToggleFullscreen={toggleFullscreen}
- />
- </div>
- );
-}
-
-function PreviewToolbar({
- isFullscreen,
+function PreviewCanvas({
onToggleFullscreen,
+ containerRef,
}: {
- isFullscreen: boolean;
onToggleFullscreen: () => void;
+ containerRef: React.RefObject<HTMLElement | null>;
}) {
- const editor = useEditor();
- const isPlaying = editor.playback.getIsPlaying();
- const currentTime = editor.playback.getCurrentTime();
- const totalDuration = editor.timeline.getTotalDuration();
- const fps = editor.project.getActive().settings.fps;
-
- return (
- <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
- <div className="flex items-center mt-1">
- <EditableTimecode
- time={currentTime}
- duration={totalDuration}
- format="HH:MM:SS:FF"
- fps={fps}
- onTimeChange={({ time }) => editor.playback.seek({ time })}
- className="text-center"
- />
- <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
- <span className="text-muted-foreground font-mono text-xs">
- {formatTimeCode({
- timeInSeconds: totalDuration,
- format: "HH:MM:SS:FF",
- fps,
- })}
- </span>
- </div>
-
- <Button
- variant="text"
- size="icon"
- type="button"
- onClick={() => invokeAction("toggle-play")}
- >
- <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
- </Button>
-
- <div className="justify-self-end">
- <Button
- variant="text"
- size="icon"
- type="button"
- onClick={onToggleFullscreen}
- title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
- >
- <HugeiconsIcon icon={FullScreenIcon} />
- </Button>
- </div>
- </div>
- );
-}
-
-function PreviewCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
+ const outerContainerRef = useRef<HTMLDivElement>(null);
+ const canvasBoundsRef = 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 });
+ const containerSize = useContainerSize({ containerRef: outerContainerRef });
const editor = useEditor();
const activeProject = editor.project.getActive();
+ const { overlays } = usePreviewStore();
const renderer = useMemo(() => {
return new CanvasRenderer({
@@ -224,24 +169,42 @@ function PreviewCanvas() {
return (
<div
- ref={containerRef}
- className="relative flex h-full w-full items-center justify-center"
+ ref={outerContainerRef}
+ className="relative flex size-full items-center justify-center"
>
- <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,
- }}
- />
- <PreviewInteractionOverlay canvasRef={canvasRef} />
+ <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,
+ }}
+ />
+ <PreviewInteractionOverlay
+ canvasRef={canvasRef}
+ containerRef={canvasBoundsRef}
+ />
+ {overlays.bookmarks && <BookmarkNoteOverlay />}
+ </div>
+ </ContextMenuTrigger>
+ <PreviewContextMenu
+ onToggleFullscreen={onToggleFullscreen}
+ containerRef={containerRef}
+ />
+ </ContextMenu>
</div>
);
}
diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
index 0f02da1c..44efb4c8 100644
--- a/apps/web/src/components/editor/layout-guide-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
@@ -1,27 +1,27 @@
-"use client";
-
-import { useEditorStore } from "@/stores/editor-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 } = useEditorStore();
-
- if (layoutGuide.platform === null) return null;
- if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
-
- return null;
-}
+"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/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
index c13379f0..4c33a1a1 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,20 +1,54 @@
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
-import { cn } from "@/utils/ui";
+import { TransformHandles } from "./transform-handles";
+import { SnapGuides } from "./snap-guides";
+import { TextEditOverlay } from "./text-edit-overlay";
export function PreviewInteractionOverlay({
canvasRef,
+ containerRef,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
}) {
- const { onPointerDown, onPointerMove, onPointerUp } =
- usePreviewInteraction({ canvasRef });
+ const {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onDoubleClick,
+ snapLines,
+ editingText,
+ commitTextEdit,
+ cancelTextEdit,
+ } = usePreviewInteraction({ canvasRef });
return (
- <div
- className={cn("absolute inset-0 pointer-events-auto")}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
+ <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}
+ onDoubleClick={onDoubleClick}
+ />
+ {editingText ? (
+ <TextEditOverlay
+ canvasRef={canvasRef}
+ containerRef={containerRef}
+ trackId={editingText.trackId}
+ elementId={editingText.elementId}
+ element={editingText.element}
+ onCommit={commitTextEdit}
+ onCancel={cancelTextEdit}
+ />
+ ) : (
+ <TransformHandles canvasRef={canvasRef} containerRef={containerRef} />
+ )}
+ <SnapGuides
+ lines={snapLines}
+ canvasRef={canvasRef}
+ containerRef={containerRef}
+ />
+ </div>
);
}
diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
new file mode 100644
index 00000000..4aa714a1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+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();
+
+ if (!canvasRect || !containerRect || lines.length === 0) {
+ return null;
+ }
+
+ const toOverlayX = (logicalX: number) =>
+ positionToOverlay({
+ positionX: logicalX,
+ positionY: 0,
+ canvasRect,
+ containerRect,
+ canvasSize,
+ }).x;
+
+ const toOverlayY = (logicalY: number) =>
+ positionToOverlay({
+ positionX: 0,
+ positionY: logicalY,
+ canvasRect,
+ containerRect,
+ canvasSize,
+ }).y;
+
+ return (
+ <div className="pointer-events-none absolute inset-0" aria-hidden>
+ {lines.map((line) => {
+ if (line.type === "vertical") {
+ return (
+ <div
+ key={`vertical-${line.position}`}
+ className="absolute top-0 bottom-0 w-px bg-white/70"
+ style={{ left: toOverlayX(line.position) }}
+ />
+ );
+ }
+ return (
+ <div
+ key={`horizontal-${line.position}`}
+ className="absolute left-0 right-0 h-px bg-white/70"
+ style={{ top: toOverlayY(line.position) }}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
new file mode 100644
index 00000000..265e972d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import type { TextElement } from "@/types/timeline";
+import {
+ positionToOverlay,
+ getDisplayScale,
+} from "@/lib/preview/preview-coords";
+import {
+ DEFAULT_LINE_HEIGHT,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+
+const TEXT_BACKGROUND_PADDING = "4px 8px";
+const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
+
+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 divRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const div = divRef.current;
+ if (!div) return;
+ div.focus();
+ const range = document.createRange();
+ range.selectNodeContents(div);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }, []);
+
+ const handleInput = useCallback(() => {
+ const div = divRef.current;
+ if (!div) return;
+ const text = div.innerText;
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: { content: text } }],
+ });
+ }, [editor.timeline, trackId, elementId]);
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ const { key } = event;
+ if (key === "Escape") {
+ event.preventDefault();
+ onCancel();
+ return;
+ }
+ },
+ [onCancel],
+ );
+
+ const canvasRect = canvasRef.current?.getBoundingClientRect();
+ const containerRect = containerRef.current?.getBoundingClientRect();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ if (!canvasRect || !containerRect || !canvasSize) return null;
+
+ const { x: posX, y: posY } = positionToOverlay({
+ positionX: element.transform.position.x,
+ positionY: element.transform.position.y,
+ canvasRect,
+ containerRect,
+ canvasSize,
+ });
+
+ const { x: displayScaleX } = getDisplayScale({
+ canvasRect,
+ canvasSize,
+ });
+
+ 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 fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
+ const letterSpacing = element.letterSpacing ?? 0;
+ const backgroundColor =
+ element.backgroundColor === "transparent"
+ ? "transparent"
+ : element.backgroundColor;
+
+ return (
+ <div
+ className="absolute"
+ style={{
+ left: posX,
+ top: posY - verticalAlignmentOffset,
+ transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
+ transformOrigin: "center center",
+ }}
+ >
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
+ <div
+ ref={divRef}
+ contentEditable
+ suppressContentEditableWarning
+ tabIndex={0}
+ role="textbox"
+ aria-label="Edit text"
+ className="cursor-text select-text outline-none whitespace-pre"
+ style={{
+ fontSize: displayFontSize,
+ fontFamily: element.fontFamily,
+ fontWeight,
+ fontStyle,
+ textAlign: element.textAlign,
+ letterSpacing: `${letterSpacing}px`,
+ lineHeight,
+ color: element.color,
+ backgroundColor,
+ minHeight: displayFontSize * lineHeight,
+ textDecoration: element.textDecoration ?? "none",
+ padding:
+ backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
+ minWidth: 1,
+ }}
+ onInput={handleInput}
+ onBlur={onCommit}
+ onKeyDown={handleKeyDown}
+ >
+ {element.content || ""}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
new file mode 100644
index 00000000..7f5cd9bc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import { formatTimeCode } from "@/lib/time";
+import { invokeAction } from "@/lib/actions";
+import { EditableTimecode } from "@/components/editable-timecode";
+import { Button } from "@/components/ui/button";
+import {
+ FullScreenIcon,
+ PauseIcon,
+ PlayIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { OcSocialIcon } from "@opencut/ui/icons";
+import { Separator } from "@/components/ui/separator";
+
+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;
+
+ 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>
+
+ <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>
+ <Separator orientation="vertical" className="h-4" />
+ <Button
+ variant="text"
+ onClick={onToggleFullscreen}
+ title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
+ >
+ <HugeiconsIcon icon={FullScreenIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
new file mode 100644
index 00000000..976c3019
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+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 { 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",
+];
+
+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),
+ };
+}
+
+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,
+ };
+}
+
+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 };
+}
+
+export function TransformHandles({
+ canvasRef,
+ containerRef,
+}: {
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+}) {
+ const {
+ selectedWithBounds,
+ hasVisualSelection,
+ snapLines,
+ handleCornerPointerDown,
+ handleRotationPointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ } = useTransformHandles({ canvasRef });
+
+ const editor = useEditor();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ 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 toOverlay = ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) =>
+ 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 rotationHandle = getRotationHandlePosition({ bounds });
+ const rotationHandleScreen = toOverlay({
+ canvasX: rotationHandle.x,
+ canvasY: rotationHandle.y,
+ });
+
+ return (
+ <div className="pointer-events-none absolute inset-0" aria-hidden>
+ <SnapGuides
+ lines={snapLines}
+ canvasRef={canvasRef}
+ containerRef={containerRef}
+ />
+ <BoundingBoxOutline
+ center={center}
+ outlineWidth={outlineWidth}
+ outlineHeight={outlineHeight}
+ rotation={bounds.rotation}
+ />
+ {CORNERS.map((corner) => {
+ const cornerPosition = getCornerPosition({ bounds, corner });
+ const screen = toOverlay({
+ canvasX: cornerPosition.x,
+ canvasY: cornerPosition.y,
+ });
+ return (
+ <CornerHandle
+ key={corner}
+ corner={corner}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleCornerPointerDown({ event, corner })
+ }
+ onPointerMove={(event) => handlePointerMove({ event })}
+ onPointerUp={(event) => handlePointerUp({ event })}
+ />
+ );
+ })}
+ <RotationHandle
+ screen={rotationHandleScreen}
+ onPointerDown={(event) => handleRotationPointerDown({ event })}
+ onPointerMove={(event) => handlePointerMove({ event })}
+ onPointerUp={(event) => handlePointerUp({ event })}
+ />
+ </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 solid 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/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx
index 8ccf07ee..c5576dda 100644
--- a/apps/web/src/components/editor/panels/properties/empty-view.tsx
+++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx
@@ -17,4 +17,4 @@ export function EmptyView() {
</div>
</div>
);
-} \ No newline at end of file
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts
new file mode 100644
index 00000000..395666cb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts
@@ -0,0 +1,72 @@
+import { useReducer, useRef } from "react";
+import { evaluateMathExpression } from "@/utils/math";
+
+function looksLikeExpression({ input }: { input: string }): boolean {
+ const trimmed = input.trim();
+ if (!trimmed) return false;
+ if (/[+*/]/.test(input)) return true;
+ const minusIndex = trimmed.indexOf("-");
+ return minusIndex > 0;
+}
+
+export function usePropertyDraft<T>({
+ displayValue: sourceDisplay,
+ parse,
+ onPreview,
+ onCommit,
+ supportsExpressions = true,
+}: {
+ displayValue: string;
+ parse: (input: string) => T | null;
+ onPreview: (value: T) => void;
+ onCommit: () => void;
+ supportsExpressions?: boolean;
+}) {
+ const [, forceRender] = useReducer(
+ (renderVersion: number) => renderVersion + 1,
+ 0,
+ );
+ const isEditing = useRef(false);
+ const draft = useRef("");
+
+ return {
+ displayValue: isEditing.current ? draft.current : sourceDisplay,
+ scrubTo: (value: number) => {
+ const parsed = parse(String(value));
+ if (parsed !== null) onPreview(parsed);
+ },
+ commitScrub: onCommit,
+ onFocus: () => {
+ isEditing.current = true;
+ draft.current = sourceDisplay;
+ forceRender();
+ },
+ onChange: (
+ event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
+ ) => {
+ draft.current = event.target.value;
+ forceRender();
+
+ const parsed = parse(event.target.value);
+ if (parsed !== null) {
+ onPreview(parsed);
+ }
+ },
+ onBlur: () => {
+ if (
+ supportsExpressions &&
+ looksLikeExpression({ input: draft.current })
+ ) {
+ const evaluated = evaluateMathExpression({ input: draft.current });
+ if (evaluated !== null) {
+ const parsed = parse(String(evaluated));
+ if (parsed !== null) onPreview(parsed);
+ }
+ }
+ onCommit();
+ isEditing.current = false;
+ draft.current = "";
+ forceRender();
+ },
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx
deleted file mode 100644
index a682097a..00000000
--- a/apps/web/src/components/editor/panels/properties/property-item.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import { useState } from "react";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
-
-interface PropertyItemProps {
- direction?: "row" | "column";
- children: React.ReactNode;
- className?: string;
-}
-
-export function PropertyItem({
- direction = "row",
- children,
- className,
-}: PropertyItemProps) {
- return (
- <div
- className={cn(
- "flex gap-2",
- direction === "row"
- ? "items-center justify-between gap-6"
- : "flex-col gap-1.5",
- className,
- )}
- >
- {children}
- </div>
- );
-}
-
-export function PropertyItemLabel({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
- <span className={cn("text-muted-foreground text-xs", className)}>
- {children}
- </span>
- );
-}
-
-export function PropertyItemValue({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
-}
-
-interface PropertyGroupProps {
- title: string;
- children: React.ReactNode;
- defaultExpanded?: boolean;
- collapsible?: boolean;
- className?: string;
- hasBorderTop?: boolean;
- hasBorderBottom?: boolean;
-}
-
-export function PropertyGroup({
- title,
- children,
- defaultExpanded = true,
- collapsible = true,
- className,
- hasBorderTop = true,
- hasBorderBottom = true,
-}: PropertyGroupProps) {
- const [isExpanded, setIsExpanded] = useState(defaultExpanded);
-
- return (
- <div
- className={cn(
- "flex flex-col",
- hasBorderTop && "border-t",
- hasBorderBottom && "last:border-b",
- className,
- )}
- >
- {collapsible ? (
- <button
- type="button"
- className="flex items-center justify-between p-3.5 cursor-pointer"
- onClick={() => setIsExpanded(!isExpanded)}
- >
- <PropertyGroupTitle isExpanded={isExpanded}>
- {title}
- </PropertyGroupTitle>
- <HugeiconsIcon
- icon={isExpanded ? MinusSignIcon : PlusSignIcon}
- className={cn(
- "size-3",
- isExpanded ? "text-foreground" : "text-muted-foreground",
- )}
- />
- </button>
- ) : (
- <div className="flex items-center justify-between p-4">
- <PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle>
- </div>
- )}
- {(collapsible ? isExpanded : true) && (
- <div className="p-3 pt-0">{children}</div>
- )}
- </div>
- );
-}
-
-function PropertyGroupTitle({
- children,
- isExpanded = false,
-}: {
- children: React.ReactNode;
- isExpanded?: boolean;
-}) {
- return (
- <span
- className={cn(
- "text-xs font-medium",
- isExpanded ? "text-foreground" : "text-muted-foreground",
- )}
- >
- {children}
- </span>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx
new file mode 100644
index 00000000..0b45065f
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/section.tsx
@@ -0,0 +1,161 @@
+import { createContext, useContext, useState } from "react";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowDownIcon } from "@hugeicons/core-free-icons";
+
+const sectionExpandedCache = new Map<string, boolean>();
+
+interface SectionContext {
+ isOpen: boolean;
+ toggle: () => void;
+ collapsible: boolean;
+}
+
+const SectionCtx = createContext<SectionContext | null>(null);
+
+function useSectionContext() {
+ return useContext(SectionCtx);
+}
+
+interface SectionProps {
+ children: React.ReactNode;
+ collapsible?: boolean;
+ defaultOpen?: boolean;
+ sectionKey?: string;
+ className?: string;
+ hasBorderTop?: boolean;
+ hasBorderBottom?: boolean;
+}
+
+export function Section({
+ children,
+ collapsible = false,
+ defaultOpen = true,
+ sectionKey,
+ className,
+ hasBorderTop = true,
+ hasBorderBottom = true,
+}: SectionProps) {
+ const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
+ const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
+
+ const toggle = () => {
+ const next = !isOpen;
+ setIsOpen(next);
+ if (sectionKey) sectionExpandedCache.set(sectionKey, next);
+ };
+
+ return (
+ <SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
+ <div
+ className={cn(
+ "flex flex-col",
+ hasBorderTop && "border-t",
+ hasBorderBottom && "last:border-b",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ </SectionCtx.Provider>
+ );
+}
+
+interface SectionHeaderProps {
+ title: string;
+ children?: React.ReactNode;
+ onClick?: () => void;
+ className?: string;
+}
+
+export function SectionHeader({
+ title,
+ children,
+ onClick,
+ className,
+}: SectionHeaderProps) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+ const isInteractive = isCollapsible || !!onClick;
+
+ const handleClick = isCollapsible ? ctx?.toggle : onClick;
+
+ const content = (
+ <>
+ <span
+ className={cn(
+ "text-sm font-medium",
+ isOpen ? "text-foreground" : "text-muted-foreground",
+ )}
+ >
+ {title}
+ </span>
+ <div className="flex items-center gap-1">
+ {children}
+ {isCollapsible && (
+ <HugeiconsIcon
+ icon={ArrowDownIcon}
+ className={cn(
+ "size-3 shrink-0 transition-transform duration-200 ease-out",
+ isOpen
+ ? "rotate-0 text-foreground"
+ : "-rotate-90 text-muted-foreground",
+ )}
+ />
+ )}
+ </div>
+ </>
+ );
+
+ const baseClassName = cn(
+ "flex w-full items-center justify-between h-11 px-3.5",
+ className,
+ );
+
+ if (isInteractive) {
+ return (
+ <button
+ type="button"
+ className={cn(baseClassName, "cursor-pointer text-left")}
+ onClick={(event) => {
+ handleClick?.();
+ event.currentTarget.blur();
+ }}
+ >
+ {content}
+ </button>
+ );
+ }
+
+ return <div className={baseClassName}>{content}</div>;
+}
+
+export function SectionContent({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+
+ if (isCollapsible) {
+ return (
+ <div
+ className={cn(
+ "grid transition-[grid-template-rows] duration-100 ease-out",
+ isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
+ )}
+ >
+ <div className="overflow-hidden">
+ <div className={cn("p-4 pt-0", className)}>{children}</div>
+ </div>
+ </div>
+ );
+ }
+
+ return <div className={cn("p-4 pt-0", className)}>{children}</div>;
+}
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
new file mode 100644
index 00000000..430efa25
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
@@ -0,0 +1,196 @@
+import { useEditor } from "@/hooks/use-editor";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+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, SectionHeader } from "../section";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { BlendMode } from "@/types/rendering";
+import type { ElementType } from "@/types/timeline";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { RainDropIcon } from "@hugeicons/core-free-icons";
+
+type BlendingElement = {
+ id: string;
+ opacity: number;
+ type: ElementType;
+ blendMode?: BlendMode;
+};
+
+const BLEND_MODE_GROUPS = [
+ [{ value: "normal", label: "Normal" }],
+ [
+ { value: "darken", label: "Darken" },
+ { value: "multiply", label: "Multiply" },
+ { value: "color-burn", label: "Color Burn" },
+ ],
+ [
+ { value: "lighten", label: "Lighten" },
+ { value: "screen", label: "Screen" },
+ { value: "plus-lighter", label: "Plus Lighter" },
+ { value: "color-dodge", label: "Color Dodge" },
+ ],
+ [
+ { value: "overlay", label: "Overlay" },
+ { value: "soft-light", label: "Soft Light" },
+ { value: "hard-light", label: "Hard Light" },
+ ],
+ [
+ { value: "difference", label: "Difference" },
+ { value: "exclusion", label: "Exclusion" },
+ ],
+ [
+ { value: "hue", label: "Hue" },
+ { value: "saturation", label: "Saturation" },
+ { value: "color", label: "Color" },
+ { value: "luminosity", label: "Luminosity" },
+ ],
+];
+
+export function BlendingSection({
+ element,
+ trackId,
+}: {
+ element: BlendingElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
+ const didSelectRef = useRef(false);
+ const committedBlendModeRef = useRef(blendMode);
+ if (!editor.timeline.isPreviewActive()) {
+ committedBlendModeRef.current = blendMode;
+ }
+
+ const previewBlendMode = (value: BlendMode) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { blendMode: value } },
+ ],
+ });
+
+ const commitBlendMode = (value: string) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { blendMode: value as BlendMode },
+ },
+ ],
+ });
+ }
+ didSelectRef.current = true;
+ };
+
+ const handleBlendModeOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ if (!didSelectRef.current) editor.timeline.discardPreview();
+ didSelectRef.current = false;
+ }
+ };
+
+ const opacity = usePropertyDraft({
+ displayValue: Math.round(element.opacity * 100).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min: 0, max: 100 }) / 100;
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { opacity: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.type}:blending`}>
+ <SectionHeader title="Blending" />
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <div className="w-1/2 space-y-1.5">
+ <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={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: DEFAULT_OPACITY },
+ },
+ ],
+ })
+ }
+ isDefault={element.opacity === DEFAULT_OPACITY}
+ dragSensitivity="slow"
+ />
+ </div>
+ <div className="w-1/2 space-y-1.5">
+ <Select
+ value={committedBlendModeRef.current}
+ onOpenChange={handleBlendModeOpenChange}
+ onValueChange={commitBlendMode}
+ >
+ <SelectTrigger
+ icon={<HugeiconsIcon icon={RainDropIcon} />}
+ className="w-full"
+ >
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36">
+ {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(option.value as BlendMode)
+ }
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+ <SelectSeparator />
+ ) : null}
+ </Fragment>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx
new file mode 100644
index 00000000..8be58f39
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/sections/index.tsx
@@ -0,0 +1,2 @@
+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
new file mode 100644
index 00000000..17a54b4c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
@@ -0,0 +1,248 @@
+import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { clamp } from "@/utils/math";
+import type { ElementType, Transform } from "@/types/timeline";
+import { Section, SectionContent, SectionHeader } 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";
+
+type TransformElement = {
+ id: string;
+ transform: Transform;
+ type: ElementType;
+};
+
+function parseFloat_({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function TransformSection({
+ element,
+ trackId,
+}: {
+ element: TransformElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const [isScaleLocked, setIsScaleLocked] = useState(false);
+
+ const previewTransform = (transform: Partial<Transform>) => {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { transform: { ...element.transform, ...transform } },
+ },
+ ],
+ });
+ };
+
+ const commit = () => editor.timeline.commitPreview();
+
+ const positionX = usePropertyDraft({
+ displayValue: Math.round(element.transform.position.x).toString(),
+ parse: (input) => parseFloat_({ input }),
+ onPreview: (value) =>
+ previewTransform({
+ position: { ...element.transform.position, x: value },
+ }),
+ onCommit: commit,
+ });
+
+ const positionY = usePropertyDraft({
+ displayValue: Math.round(element.transform.position.y).toString(),
+ parse: (input) => parseFloat_({ input }),
+ onPreview: (value) =>
+ previewTransform({
+ position: { ...element.transform.position, y: value },
+ }),
+ onCommit: commit,
+ });
+
+ const scale = usePropertyDraft({
+ displayValue: Math.round(element.transform.scale * 100).toString(),
+ parse: (input) => {
+ const parsed = parseFloat_({ input });
+ if (parsed === null) return null;
+ return Math.max(parsed, 1) / 100;
+ },
+ onPreview: (value) => previewTransform({ scale: value }),
+ onCommit: commit,
+ });
+ 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: () =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ scale: DEFAULT_TRANSFORM.scale,
+ },
+ },
+ },
+ ],
+ }),
+ isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
+ };
+
+ const rotation = usePropertyDraft({
+ displayValue: Math.round(element.transform.rotate).toString(),
+ parse: (input) => {
+ const parsed = parseFloat_({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ onPreview: (value) => previewTransform({ rotate: value }),
+ onCommit: commit,
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.type}:transform`}>
+ <SectionHeader title="Transform" />
+ <SectionContent>
+ <div className="flex flex-col gap-2">
+ <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
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ </div>
+ <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={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ x: DEFAULT_TRANSFORM.position.x,
+ },
+ },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={
+ element.transform.position.x === 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={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ y: DEFAULT_TRANSFORM.position.y,
+ },
+ },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={
+ element.transform.position.y === DEFAULT_TRANSFORM.position.y
+ }
+ />
+ </div>
+
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ className="flex-1"
+ value={rotation.displayValue}
+ onFocus={rotation.onFocus}
+ onChange={rotation.onChange}
+ onBlur={rotation.onBlur}
+ dragSensitivity="slow"
+ onScrub={rotation.scrubTo}
+ onScrubEnd={rotation.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ rotate: DEFAULT_TRANSFORM.rotate,
+ },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
+ />
+ </div>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx
index 22e50e4e..cfca52dc 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -1,24 +1,31 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
-import type { FontFamily } from "@/constants/font-constants";
import type { TextElement } from "@/types/timeline";
import { Slider } from "@/components/ui/slider";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { useReducer, useRef } from "react";
-import { PanelBaseView } from "@/components/editor/panels/panel-base-view";
-import {
- PropertyGroup,
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "./property-item";
+import { NumberField } from "@/components/ui/number-field";
+import { useRef } from "react";
+import { Section, SectionContent, SectionHeader } from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
-import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants";
+import {
+ DEFAULT_TEXT_ELEMENT,
+ MAX_FONT_SIZE,
+ MIN_FONT_SIZE,
+} from "@/constants/text-constants";
+import { usePropertyDraft } from "./hooks/use-property-draft";
+import { TransformSection, BlendingSection } from "./sections";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@/components/ui/select";
+import { OcFontWeightIcon } from "@opencut/ui/icons";
+import { Label } from "@/components/ui/label";
export function TextProperties({
element,
@@ -27,624 +34,229 @@ export function TextProperties({
element: TextElement;
trackId: string;
}) {
- const editor = useEditor();
- const containerRef = useRef<HTMLDivElement>(null);
- const [, forceRender] = useReducer((x: number) => x + 1, 0);
- const isEditingFontSize = useRef(false);
- const isEditingOpacity = useRef(false);
- const isEditingContent = useRef(false);
- const fontSizeDraft = useRef("");
- const opacityDraft = useRef("");
- const contentDraft = useRef("");
-
- const fontSizeDisplay = isEditingFontSize.current
- ? fontSizeDraft.current
- : element.fontSize.toString();
- const opacityDisplay = isEditingOpacity.current
- ? opacityDraft.current
- : Math.round(element.opacity * 100).toString();
- const contentDisplay = isEditingContent.current
- ? contentDraft.current
- : element.content;
-
- const lastSelectedColor = useRef(DEFAULT_COLOR);
- const initialFontSizeRef = useRef<number | null>(null);
- const initialOpacityRef = useRef<number | null>(null);
- const initialContentRef = useRef<string | null>(null);
- const initialColorRef = useRef<string | null>(null);
- const initialBgColorRef = useRef<string | null>(null);
-
- const handleFontSizeChange = ({ value }: { value: string }) => {
- fontSizeDraft.current = value;
- forceRender();
+ return (
+ <div className="flex h-full flex-col">
+ <ContentSection element={element} trackId={trackId} />
+ <TransformSection element={element} trackId={trackId} />
+ <BlendingSection element={element} trackId={trackId} />
+ <FontSection element={element} trackId={trackId} />
+ <ColorSection element={element} trackId={trackId} />
+ </div>
+ );
+}
- if (value.trim() !== "") {
- if (initialFontSizeRef.current === null) {
- initialFontSizeRef.current = element.fontSize;
- }
- const parsed = parseInt(value, 10);
- const fontSize = Number.isNaN(parsed)
- ? element.fontSize
- : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
- editor.timeline.updateElements({
- updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
- pushHistory: false,
- });
- }
- };
+function ContentSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
- const handleFontSizeBlur = () => {
- if (initialFontSizeRef.current !== null) {
- const parsed = parseInt(fontSizeDraft.current, 10);
- const fontSize = Number.isNaN(parsed)
- ? element.fontSize
- : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
- editor.timeline.updateElements({
+ const content = usePropertyDraft({
+ displayValue: element.content,
+ parse: (input) => input,
+ onPreview: (value) =>
+ editor.timeline.previewElements({
updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontSize: initialFontSizeRef.current },
- },
+ { trackId, elementId: element.id, updates: { content: value } },
],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
- pushHistory: true,
- });
- initialFontSizeRef.current = null;
- }
- isEditingFontSize.current = false;
- fontSizeDraft.current = "";
- forceRender();
- };
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
- const handleOpacityChange = ({ value }: { value: string }) => {
- opacityDraft.current = value;
- forceRender();
-
- if (value.trim() !== "") {
- if (initialOpacityRef.current === null) {
- initialOpacityRef.current = element.opacity;
- }
- const parsed = parseInt(value, 10);
- const opacityPercent = Number.isNaN(parsed)
- ? Math.round(element.opacity * 100)
- : clamp({ value: parsed, min: 0, max: 100 });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: opacityPercent / 100 },
- },
- ],
- pushHistory: false,
- });
- }
- };
+ return (
+ <Section collapsible sectionKey="text:content" hasBorderTop={false}>
+ <SectionHeader title="Content" />
+ <SectionContent>
+ <Textarea
+ placeholder="Name"
+ value={content.displayValue}
+ className="min-h-20"
+ onFocus={content.onFocus}
+ onChange={content.onChange}
+ onBlur={content.onBlur}
+ />
+ </SectionContent>
+ </Section>
+ );
+}
- const handleOpacityBlur = () => {
- if (initialOpacityRef.current !== null) {
- const parsed = parseInt(opacityDraft.current, 10);
- const opacityPercent = Number.isNaN(parsed)
- ? Math.round(element.opacity * 100)
- : clamp({ value: parsed, min: 0, max: 100 });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: initialOpacityRef.current },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: opacityPercent / 100 },
- },
- ],
- pushHistory: true,
- });
- initialOpacityRef.current = null;
- }
- isEditingOpacity.current = false;
- opacityDraft.current = "";
- forceRender();
- };
+function FontSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
- const handleColorChange = ({ color }: { color: string }) => {
- if (color !== "transparent") {
- lastSelectedColor.current = color;
- }
- if (initialBgColorRef.current === null) {
- initialBgColorRef.current = element.backgroundColor;
- }
- if (initialBgColorRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: color },
- },
- ],
- pushHistory: false,
- });
- } else {
- editor.timeline.updateElements({
+ const fontSize = usePropertyDraft({
+ displayValue: element.fontSize.toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: color },
- },
+ { trackId, elementId: element.id, updates: { fontSize: value } },
],
- });
- }
- };
-
- const handleColorChangeEnd = ({ color }: { color: string }) => {
- if (initialBgColorRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: initialBgColorRef.current },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: `#${color}` },
- },
- ],
- pushHistory: true,
- });
- initialBgColorRef.current = null;
- }
- };
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
return (
- <div className="flex h-full flex-col" ref={containerRef}>
- <PanelBaseView className="p-0">
- <PropertyGroup title="Content" hasBorderTop={false} collapsible={false}>
- <Textarea
- placeholder="Name"
- value={contentDisplay}
- className="bg-accent min-h-20"
- onFocus={() => {
- isEditingContent.current = true;
- contentDraft.current = element.content;
- initialContentRef.current = element.content;
- forceRender();
- }}
- onChange={(event) => {
- contentDraft.current = event.target.value;
- forceRender();
- if (initialContentRef.current === null) {
- initialContentRef.current = element.content;
- }
+ <Section collapsible sectionKey="text:font">
+ <SectionHeader title="Font" />
+ <SectionContent>
+ <div className="flex flex-col gap-2">
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value) =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { content: event.target.value },
+ updates: { fontFamily: value },
},
],
- pushHistory: false,
- });
- }}
- onBlur={() => {
- if (initialContentRef.current !== null) {
- const finalContent = contentDraft.current;
- editor.timeline.updateElements({
+ })
+ }
+ />
+ <Select value={element.fontWeight}>
+ <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
+ <SelectValue placeholder="Select weight" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="100">Thin</SelectItem>
+ <SelectItem value="200">Extra Light</SelectItem>
+ <SelectItem value="300">Light</SelectItem>
+ <SelectItem value="400">Normal</SelectItem>
+ <SelectItem value="500">Medium</SelectItem>
+ <SelectItem value="600">Semi Bold</SelectItem>
+ <SelectItem value="700">Bold</SelectItem>
+ <SelectItem value="800">Extra Bold</SelectItem>
+ <SelectItem value="900">Black</SelectItem>
+ </SelectContent>
+ </Select>
+
+ <Label>Font size</Label>
+ <div className="flex items-center gap-2">
+ <Slider
+ value={[element.fontSize]}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ step={1}
+ onValueChange={([value]) =>
+ editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { content: initialContentRef.current },
+ updates: { fontSize: value },
},
],
- pushHistory: false,
- });
+ })
+ }
+ onValueCommit={() => editor.timeline.commitPreview()}
+ className="w-full"
+ />
+ <NumberField
+ className="w-18 shrink-0"
+ value={fontSize.displayValue}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ onFocus={fontSize.onFocus}
+ onChange={fontSize.onChange}
+ onBlur={fontSize.onBlur}
+ onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { content: finalContent },
+ updates: {
+ fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
+ },
},
],
- pushHistory: true,
- });
- initialContentRef.current = null;
+ })
}
- isEditingContent.current = false;
- contentDraft.current = "";
- forceRender();
+ isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ />
+ </div>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function ColorSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+
+ return (
+ <Section collapsible sectionKey="text:color" hasBorderBottom={false}>
+ <SectionHeader title="Color" />
+ <SectionContent>
+ <div className="flex flex-col gap-6">
+ <ColorPicker
+ value={uppercase({
+ string: (element.color || "FFFFFF").replace("#", ""),
+ })}
+ onChange={(color) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { color: `#${color}` },
+ },
+ ],
+ })
+ }
+ onChangeEnd={() => editor.timeline.commitPreview()}
+ />
+ <ColorPicker
+ value={
+ element.backgroundColor === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : element.backgroundColor.replace("#", "")
+ }
+ onChange={(color) => {
+ const hexColor = `#${color}`;
+ if (color !== "transparent") {
+ lastSelectedColor.current = hexColor;
+ }
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { backgroundColor: hexColor },
+ },
+ ],
+ });
}}
+ onChangeEnd={() => editor.timeline.commitPreview()}
+ className={
+ element.backgroundColor === "transparent"
+ ? "pointer-events-none opacity-50"
+ : ""
+ }
/>
- </PropertyGroup>
- <PropertyGroup title="Typography" collapsible={false}>
- <div className="space-y-6">
- <PropertyItem direction="column">
- <PropertyItemLabel>Font</PropertyItemLabel>
- <PropertyItemValue>
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value: FontFamily) =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontFamily: value },
- },
- ],
- })
- }
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Style</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Button
- variant={
- element.fontWeight === "bold" ? "default" : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontWeight:
- element.fontWeight === "bold"
- ? "normal"
- : "bold",
- },
- },
- ],
- })
- }
- className="h-8 px-3 font-bold"
- >
- B
- </Button>
- <Button
- variant={
- element.fontStyle === "italic" ? "default" : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontStyle:
- element.fontStyle === "italic"
- ? "normal"
- : "italic",
- },
- },
- ],
- })
- }
- className="h-8 px-3 italic"
- >
- I
- </Button>
- <Button
- variant={
- element.textDecoration === "underline"
- ? "default"
- : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- textDecoration:
- element.textDecoration === "underline"
- ? "none"
- : "underline",
- },
- },
- ],
- })
- }
- className="h-8 px-3 underline"
- >
- U
- </Button>
- <Button
- variant={
- element.textDecoration === "line-through"
- ? "default"
- : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- textDecoration:
- element.textDecoration === "line-through"
- ? "none"
- : "line-through",
- },
- },
- ],
- })
- }
- className="h-8 px-3 line-through"
- >
- S
- </Button>
- </div>
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Font size</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.fontSize]}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- step={1}
- onValueChange={([value]) => {
- if (initialFontSizeRef.current === null) {
- initialFontSizeRef.current = element.fontSize;
- }
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontSize: value },
- },
- ],
- pushHistory: false,
- });
- }}
- onValueCommit={([value]) => {
- if (initialFontSizeRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontSize: initialFontSizeRef.current,
- },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontSize: value },
- },
- ],
- pushHistory: true,
- });
- initialFontSizeRef.current = null;
- }
- }}
- className="w-full"
- />
- <Input
- type="number"
- value={fontSizeDisplay}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={() => {
- isEditingFontSize.current = true;
- fontSizeDraft.current = element.fontSize.toString();
- forceRender();
- }}
- onChange={(e) =>
- handleFontSizeChange({ value: e.target.value })
- }
- onBlur={handleFontSizeBlur}
- className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- </div>
- </PropertyGroup>
- <PropertyGroup title="Appearance" collapsible={false}>
- <div className="space-y-6">
- <PropertyItem direction="column">
- <PropertyItemLabel>Color</PropertyItemLabel>
- <PropertyItemValue>
- <ColorPicker
- value={uppercase({
- string: (element.color || "FFFFFF").replace("#", ""),
- })}
- onChange={(color) => {
- if (initialColorRef.current === null) {
- initialColorRef.current = element.color || "#FFFFFF";
- }
- if (initialColorRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- pushHistory: false,
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- });
- }
- }}
- onChangeEnd={(color) => {
- if (initialColorRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: initialColorRef.current },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- pushHistory: true,
- });
- initialColorRef.current = null;
- }
- }}
- containerRef={containerRef}
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Opacity</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.opacity * 100]}
- min={0}
- max={100}
- step={1}
- onValueChange={([value]) => {
- if (initialOpacityRef.current === null) {
- initialOpacityRef.current = element.opacity;
- }
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: value / 100 },
- },
- ],
- pushHistory: false,
- });
- }}
- onValueCommit={([value]) => {
- if (initialOpacityRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: initialOpacityRef.current },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: value / 100 },
- },
- ],
- pushHistory: true,
- });
- initialOpacityRef.current = null;
- }
- }}
- className="w-full"
- />
- <Input
- type="number"
- value={opacityDisplay}
- min={0}
- max={100}
- onFocus={() => {
- isEditingOpacity.current = true;
- opacityDraft.current = Math.round(
- element.opacity * 100,
- ).toString();
- forceRender();
- }}
- onChange={(e) =>
- handleOpacityChange({ value: e.target.value })
- }
- onBlur={handleOpacityBlur}
- className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Background</PropertyItemLabel>
- <PropertyItemValue>
- <ColorPicker
- value={
- element.backgroundColor === "transparent"
- ? lastSelectedColor.current.replace("#", "")
- : element.backgroundColor.replace("#", "")
- }
- onChange={(color) =>
- handleColorChange({ color: `#${color}` })
- }
- onChangeEnd={(color) => handleColorChangeEnd({ color })}
- containerRef={containerRef}
- className={
- element.backgroundColor === "transparent"
- ? "pointer-events-none opacity-50"
- : ""
- }
- />
- </PropertyItemValue>
- </PropertyItem>
- </div>
- </PropertyGroup>
- </PanelBaseView>
- </div>
+ </div>
+ </SectionContent>
+ </Section>
);
}
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index cfcb1e23..64824f9a 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -1,21 +1,75 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
+import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
+import {
+ DEFAULT_BOOKMARK_COLOR,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
+import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
-import { Bookmark02Icon } from "@hugeicons/core-free-icons";
+import {
+ ArrowTurnBackwardIcon,
+ Delete02Icon,
+} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
+import type { Bookmark } from "@/types/timeline";
+import {
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+} from "@/components/ui/popover";
+import { Input } from "@/components/ui/input";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { uppercase } from "@/utils/string";
+import { clamp } from "@/utils/math";
+
+const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
+const MIN_BOOKMARK_WIDTH_PX = 2;
+const BOOKMARK_MARKER_WIDTH_PX = 12;
+const BOOKMARK_MARKER_HEIGHT_PX = 15;
+const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2;
+const BOOKMARK_MARKER_CLIP_PATH =
+ "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)";
+
+function seekToBookmarkTime({
+ editor,
+ time,
+}: {
+ editor: EditorCore;
+ time: number;
+}) {
+ const activeProject = editor.project.getActive();
+ const duration = editor.timeline.getTotalDuration();
+ const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
+ const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
+ editor.playback.seek({ time: snappedTime });
+}
interface TimelineBookmarksRowProps {
zoomLevel: number;
dynamicTimelineWidth: number;
- handleWheel: (e: React.WheelEvent) => void;
- handleTimelineContentClick: (e: React.MouseEvent) => void;
- handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
- handleRulerMouseDown: (e: React.MouseEvent) => void;
+ dragState: BookmarkDragState;
+ onBookmarkMouseDown: (params: {
+ event: React.MouseEvent;
+ bookmark: Bookmark;
+ }) => void;
+ handleWheel: (event: React.WheelEvent) => void;
+ handleTimelineContentClick: (event: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (event: React.MouseEvent) => void;
+ handleRulerMouseDown: (event: React.MouseEvent) => void;
}
export function TimelineBookmarksRow({
zoomLevel,
dynamicTimelineWidth,
+ dragState,
+ onBookmarkMouseDown,
handleWheel,
handleTimelineContentClick,
handleRulerTrackingMouseDown,
@@ -34,17 +88,23 @@ export function TimelineBookmarksRow({
aria-label="Timeline ruler"
type="button"
onWheel={handleWheel}
- onClick={handleTimelineContentClick}
+ onClick={(event) => {
+ if (!event.currentTarget.contains(event.target as Node)) return;
+ handleTimelineContentClick(event);
+ }}
onMouseDown={(event) => {
+ if (!event.currentTarget.contains(event.target as Node)) return;
handleRulerMouseDown(event);
handleRulerTrackingMouseDown(event);
}}
>
- {activeScene.bookmarks.map((time: number) => (
+ {activeScene.bookmarks.map((bookmark) => (
<TimelineBookmark
- key={`bookmark-row-${time}`}
- time={time}
+ key={`bookmark-${bookmark.time}`}
+ bookmark={bookmark}
zoomLevel={zoomLevel}
+ dragState={dragState}
+ onBookmarkMouseDown={onBookmarkMouseDown}
/>
))}
</button>
@@ -52,60 +112,305 @@ export function TimelineBookmarksRow({
);
}
-export function TimelineBookmark({
- time,
+function TimelineBookmark({
+ bookmark,
zoomLevel,
+ dragState,
+ onBookmarkMouseDown,
}: {
- time: number;
+ bookmark: Bookmark;
zoomLevel: number;
+ dragState: BookmarkDragState;
+ onBookmarkMouseDown: (params: {
+ event: React.MouseEvent;
+ bookmark: Bookmark;
+ }) => void;
}) {
const editor = useEditor();
- const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+ const isDragging =
+ dragState.isDragging &&
+ dragState.bookmarkTime !== null &&
+ Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
+
+ const displayTime = isDragging ? dragState.currentTime : bookmark.time;
+ const time = bookmark.time;
+ const bookmarkDuration = bookmark.duration ?? 0;
+ const durationWidth =
+ bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
+ const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
+ const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
+ const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
+ const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
+ const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
+ const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
+
+ const handleSeek = () => seekToBookmarkTime({ editor, time });
+
+ const handleClick = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.detail === 2) {
+ setIsPopoverOpen(true);
+ } else {
+ handleSeek();
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ handleSeek();
+ };
- const handleBookmarkActivate = ({
- event,
- }: {
- event:
- | React.MouseEvent<HTMLButtonElement>
- | React.KeyboardEvent<HTMLButtonElement>;
- }) => {
+ const handleMouseDown = (event: React.MouseEvent) => {
+ onBookmarkMouseDown({ event, bookmark });
+ event.preventDefault();
event.stopPropagation();
- const framesPerSecond = activeProject?.settings.fps ?? 30;
- const snappedTime = getSnappedSeekTime({
- rawTime: time,
- duration,
- fps: framesPerSecond,
- });
- editor.playback.seek({ time: snappedTime });
};
return (
- <button
- className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0"
- style={{
- left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
- }}
- aria-label={`Seek to bookmark at ${time}s`}
- type="button"
- onMouseDown={(event) => {
- event.preventDefault();
- event.stopPropagation();
- }}
- onClick={(event) => handleBookmarkActivate({ event })}
- onKeyDown={(event) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- handleBookmarkActivate({ event });
- }}
- >
- <div className="text-primary absolute top-[-1px] left-[-5px]">
- <HugeiconsIcon
- icon={Bookmark02Icon}
- aria-hidden="true"
- className="fill-primary size-3"
+ <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"}`}
+ style={{
+ left: `${bookmarkLeft}px`,
+ width: `${bookmarkWidth}px`,
+ }}
+ aria-label={`Bookmark at ${time.toFixed(1)}s`}
+ type="button"
+ onMouseDown={handleMouseDown}
+ onClick={handleClick}
+ onKeyDown={handleKeyDown}
+ >
+ {hasDurationRange ? (
+ <div
+ className="absolute opacity-30"
+ style={{
+ top: 1.5,
+ height: BOOKMARK_MARKER_HEIGHT_PX - 2.5,
+ left: BOOKMARK_HALF_WIDTH_PX,
+ width: durationWidth,
+ backgroundColor: iconColor,
+ }}
+ />
+ ) : null}
+ <div
+ className="absolute top-0 overflow-hidden"
+ style={{
+ left: 0,
+ width: BOOKMARK_HALF_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ }}
+ >
+ <div
+ className="absolute inset-0"
+ style={{
+ width: BOOKMARK_MARKER_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: "hsl(var(--background))",
+ }}
+ />
+ <div
+ className="absolute"
+ style={{
+ top: 1,
+ left: 1,
+ width: BOOKMARK_MARKER_WIDTH_PX - 2,
+ height: BOOKMARK_MARKER_HEIGHT_PX - 2,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: iconColor,
+ }}
+ />
+ </div>
+ <div
+ className="absolute top-0 overflow-hidden"
+ style={{
+ left: rightHalfLeft,
+ width: BOOKMARK_HALF_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ }}
+ >
+ <div
+ className="absolute top-0"
+ style={{
+ left: -BOOKMARK_HALF_WIDTH_PX,
+ width: BOOKMARK_MARKER_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: "hsl(var(--background))",
+ }}
+ />
+ <div
+ className="absolute"
+ style={{
+ top: 1,
+ left: 1 - BOOKMARK_HALF_WIDTH_PX,
+ width: BOOKMARK_MARKER_WIDTH_PX - 2,
+ height: BOOKMARK_MARKER_HEIGHT_PX - 2,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: iconColor,
+ }}
+ />
+ </div>
+ </button>
+ </PopoverAnchor>
+ <PopoverContent
+ className="w-64 flex flex-col gap-3 p-3"
+ align="start"
+ side="bottom"
+ sideOffset={8}
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ >
+ <BookmarkPopoverContent
+ bookmark={bookmark}
+ time={time}
+ timelineDuration={duration}
+ onPopoverClose={() => setIsPopoverOpen(false)}
/>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function BookmarkPopoverContent({
+ bookmark,
+ time,
+ timelineDuration,
+ onPopoverClose,
+}: {
+ bookmark: Bookmark;
+ time: number;
+ timelineDuration: number;
+ onPopoverClose: () => void;
+}) {
+ const editor = useEditor();
+ const [draftColorHex, setDraftColorHex] = useState(
+ (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
+ );
+
+ useEffect(() => {
+ setDraftColorHex(
+ (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
+ );
+ }, [bookmark.color]);
+
+ const handleRemove = () => {
+ editor.scenes.removeBookmark({ time });
+ onPopoverClose();
+ };
+
+ const handleUpdate = ({
+ note,
+ color,
+ duration,
+ }: Partial<{ note: string; color: string; duration: number }>) => {
+ const updates: Partial<{ note: string; color: string; duration: number }> =
+ {};
+ if (note !== undefined && note !== bookmark.note) updates.note = note;
+ if (
+ color !== undefined &&
+ color.toUpperCase() !== (bookmark.color ?? "").toUpperCase()
+ ) {
+ updates.color = color;
+ }
+ if (duration !== undefined && duration !== bookmark.duration) {
+ updates.duration = duration;
+ }
+ if (Object.keys(updates).length === 0) return;
+ editor.scenes.updateBookmark({ time, updates });
+ };
+
+ return (
+ <>
+ <div className="flex flex-col gap-2">
+ <Label className="text-xs">Note</Label>
+ <Input
+ placeholder="Add a note..."
+ value={bookmark.note ?? ""}
+ onChange={(event) => handleUpdate({ note: event.target.value })}
+ className="h-8 text-sm"
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label className="text-xs">Color</Label>
+ <div className="relative">
+ <ColorPicker
+ value={uppercase({ string: draftColorHex })}
+ onChange={(color) => setDraftColorHex(uppercase({ string: color }))}
+ onChangeEnd={(color) =>
+ handleUpdate({ color: `#${uppercase({ string: color })}` })
+ }
+ className="bg-background border"
+ />
+ {bookmark.color &&
+ bookmark.color.replace(/^#/, "").toUpperCase() !==
+ DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
+ <Button
+ type="button"
+ variant="text"
+ size="text"
+ aria-label="Reset to default color"
+ className="absolute top-1/2 right-1 -translate-y-1/2 mr-1"
+ onClick={() =>
+ editor.scenes.updateBookmark({
+ time,
+ updates: { color: undefined },
+ })
+ }
+ >
+ <HugeiconsIcon
+ icon={ArrowTurnBackwardIcon}
+ className="!size-3.5"
+ />
+ </Button>
+ )}
+ </div>
</div>
- </button>
+ <div className="flex flex-col gap-2">
+ <Label className="text-xs">Duration</Label>
+ <div className="flex items-center gap-1.5">
+ <Input
+ type="number"
+ min={0}
+ step={0.1}
+ value={bookmark.duration ?? 0}
+ onChange={(event) => {
+ const parsed = parseFloat(event.target.value);
+ const value = Number.isNaN(parsed)
+ ? 0
+ : clamp({
+ value: parsed,
+ min: 0,
+ max: Math.max(0, timelineDuration - time),
+ });
+ handleUpdate({ duration: value });
+ }}
+ className="h-8 text-sm"
+ containerClassName="w-full"
+ />
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="text-destructive hover:bg-destructive/10"
+ onClick={handleRemove}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ handleRemove();
+ }
+ }}
+ aria-label="delete bookmark"
+ >
+ <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" />
+ Delete
+ </Button>
+ </>
);
}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 94edc2d0..7e6ff326 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -47,6 +47,8 @@ import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { TimelineRuler } from "./timeline-ruler";
import { TimelineBookmarksRow } from "./bookmarks";
+import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
+import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
@@ -126,6 +128,17 @@ export function Timeline() {
onSnapPointChange: handleSnapPointChange,
});
+ const {
+ dragState: bookmarkDragState,
+ handleBookmarkMouseDown,
+ lastMouseXRef: bookmarkLastMouseXRef,
+ } = useBookmarkDrag({
+ zoomLevel,
+ scrollRef: tracksScrollRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
+
const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
useTimelinePlayhead({
zoomLevel,
@@ -168,10 +181,18 @@ export function Timeline() {
containerWidth,
);
+ useEdgeAutoScroll({
+ isActive: bookmarkDragState.isDragging,
+ getMouseClientX: () => bookmarkLastMouseXRef.current,
+ rulerScrollRef: tracksScrollRef,
+ tracksScrollRef,
+ contentWidth: dynamicTimelineWidth,
+ });
+
const showSnapIndicator =
snappingEnabled &&
currentSnapPoint !== null &&
- (dragState.isDragging || isResizing);
+ (dragState.isDragging || bookmarkDragState.isDragging || isResizing);
const {
handleTracksMouseDown,
@@ -351,7 +372,7 @@ export function Timeline() {
>
<div
ref={timelineHeaderRef}
- className="bg-background sticky top-0 z-30 flex flex-col"
+ className="bg-background sticky top-0 flex flex-col"
>
<TimelineRuler
zoomLevel={zoomLevel}
@@ -366,6 +387,8 @@ export function Timeline() {
<TimelineBookmarksRow
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
+ dragState={bookmarkDragState}
+ onBookmarkMouseDown={handleBookmarkMouseDown}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
@@ -433,7 +456,7 @@ export function Timeline() {
/>
</div>
</ContextMenuTrigger>
- <ContextMenuContent className="z-200 w-40">
+ <ContextMenuContent className="w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
onClick={(e) => {
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 914edccb..e70f0cd7 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -38,7 +38,7 @@ export function SnapIndicator({
return (
<div
- className="pointer-events-none absolute z-90"
+ className="pointer-events-none absolute"
style={{
left: `${leftPosition}px`,
top: topPosition,
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 59ce88da..628855f6 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -29,6 +29,7 @@ import type { MediaAsset } from "@/types/assets";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { resolveStickerId } from "@/lib/stickers";
import Image from "next/image";
import {
ScissorIcon,
@@ -139,9 +140,7 @@ export function TimelineElement({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
- className={`absolute top-0 h-full select-none ${
- isBeingDragged ? "z-30" : "z-10"
- }`}
+ className={`absolute top-0 h-full select-none`}
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
@@ -155,7 +154,6 @@ export function TimelineElement({
element={element}
track={track}
isSelected={isSelected}
- isBeingDragged={isBeingDragged}
hasAudio={hasAudio}
isMuted={isMuted}
mediaAssets={mediaAssets}
@@ -165,7 +163,7 @@ export function TimelineElement({
/>
</div>
</ContextMenuTrigger>
- <ContextMenuContent className="z-200 w-64">
+ <ContextMenuContent className="w-64">
<ActionMenuItem
action="split"
icon={<HugeiconsIcon icon={ScissorIcon} />}
@@ -227,7 +225,6 @@ function ElementInner({
element,
track,
isSelected,
- isBeingDragged,
hasAudio,
isMuted,
mediaAssets,
@@ -238,7 +235,6 @@ function ElementInner({
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
- isBeingDragged: boolean;
hasAudio: boolean;
isMuted: boolean;
mediaAssets: MediaAsset[];
@@ -248,7 +244,7 @@ function ElementInner({
element: TimelineElementType,
) => void;
handleResizeStart: (params: {
- e: React.MouseEvent;
+ event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => void;
@@ -259,7 +255,7 @@ function ElementInner({
{
type: track.type,
},
- )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
+ )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
>
<button
type="button"
@@ -321,7 +317,7 @@ function ResizeHandle({
side: "left" | "right";
elementId: string;
handleResizeStart: (params: {
- e: React.MouseEvent;
+ event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => void;
@@ -330,8 +326,8 @@ function ResizeHandle({
return (
<button
type="button"
- className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
- onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
+ className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
+ onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
>
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
@@ -362,7 +358,10 @@ function ElementContent({
return (
<div className="flex size-full items-center gap-2 pl-2">
<Image
- src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
+ src={resolveStickerId({
+ stickerId: element.stickerId,
+ options: { width: 20, height: 20 },
+ })}
alt={element.name}
className="size-5 shrink-0"
width={20}
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 8af35dc9..ebb2a37e 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -72,20 +72,22 @@ export function TimelinePlayhead({
aria-valuemax={duration}
aria-valuenow={playheadPosition}
tabIndex={0}
- className="pointer-events-auto absolute z-60"
+ className="pointer-events-none absolute z-5"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px",
}}
- onMouseDown={handlePlayheadMouseDown}
onKeyDown={handlePlayheadKeyDown}
>
- <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
+ <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" />
- <div
- className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
+ <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"}`}
+ onMouseDown={handlePlayheadMouseDown}
/>
</div>
);
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 0b02c63f..03ce88eb 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -171,7 +171,7 @@ function SceneSelector() {
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator />
<ScenesView>
- <SplitButtonRight onClick={() => {}} type="button">
+ <SplitButtonRight onClick={() => {}}>
<HugeiconsIcon icon={Layers01Icon} className="size-4" />
</SplitButtonRight>
</ScenesView>
@@ -222,7 +222,6 @@ function ToolbarRightSection({
<Button
variant="text"
size="icon"
- type="button"
onClick={() => onZoom({ direction: "out" })}
>
<HugeiconsIcon icon={SearchMinusIcon} />
@@ -240,7 +239,6 @@ function ToolbarRightSection({
<Button
variant="text"
size="icon"
- type="button"
onClick={() => onZoom({ direction: "in" })}
>
<HugeiconsIcon icon={SearchAddIcon} />
@@ -269,7 +267,6 @@ function ToolbarButton({
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
- type="button"
onClick={(event) => onClick({ event })}
className={cn(
"rounded-sm",