feat: Clip effects, asset sorting, and timeline improvements

Major features and improvements:

* **Clip Effects**:
  * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder).
  * Implemented dynamic parameter fields for effects.
  * Added support for keyframing effect parameters.

* **Assets Panel**:
  * Added sorting options: Name, Type, Duration, and File Size.
  * Persisted view preferences (grid/list mode, sort order) to local storage.
  * Refactored media item rendering and drag interactions.

* **Timeline & Interaction**:
  * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element.
  * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps).
  * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline.
  * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key.
  * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled.

* **Text Elements**:
  * Refactored text background storage to use an explicit `enabled` flag.
  * Added `V8toV9` storage migration to update existing projects.

* **Architecture**:
  * Moved export state management to `ProjectManager` for better lifecycle handling.
  * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
This commit is contained in:
Maze Winther 2026-03-02 13:13:07 +01:00
parent 93bea01c9e
commit e7dcb586c0
66 changed files with 3688 additions and 1333 deletions

View File

@ -14,4 +14,30 @@ changes:
text: "Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below."
- type: fixed
text: "Audio could flicker or cut out at the start of playback when certain elements were selected. The scheduler now recovers from timing slips without losing audio."
- type: fixed
text: "Dragging a clip onto the timeline and pressing Ctrl+Z now removes both the clip and the track it created in one step, instead of requiring two undos."
- type: fixed
text: "A few values in the properties panel were previously showing incorrect values. This has been fixed."
- type: improved
text: "New text elements no longer have a black background by default."
- type: improved
text: "Selection outline in the preview is now dashed."
- type: improved
text: "Adjacent clips on the timeline now have a small gap between them so selected clips are always clearly visible."
- type: fixed
text: "Timeline trim handles now sit outside the clip instead of inside them, so the clip's visible area accurately represents where it starts and ends."
- type: fixed
text: "Clips being dragged on the timeline now render on top of other clips instead of underneath them."
- type: fixed
text: "Undoing a trim-from-start operation now correctly restores the clip to its original position and length, instead of stretching it to the right."
- type: fixed
text: "Resizing a clip on the timeline would deselect it when the mouse was released. The clip now stays selected after a resize."
- type: improved
text: "Press Escape to deselect the current element."
- type: fixed
text: "Resizing a clip could extend it past another clip on the same track, causing an overlap. The resize now stops at the next clip's edge."
- type: fixed
text: "Dragging a clip to a different track would deselect it on drop. The clip stays selected after moving."
- type: fixed
text: "Finishing a resize or drag with the mouse over empty track space would deselect the clip."
---

View File

@ -60,7 +60,7 @@ export function ReleaseTitle({
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
{href ? (
<Link href={href} className="hover:underline underline-offset-4">
<>{children}</>
{children}
</Link>
) : (
children

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useState } from "react";
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@ -14,35 +14,47 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/utils/ui";
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export";
import { Check, Copy, Download, RotateCcw } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
type ExportFormat,
type ExportQuality,
type ExportResult,
} from "@/types/export";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/editor/panels/properties/section";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
}
function isExportQuality(value: string): value is ExportQuality {
return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
}
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const editor = useEditor();
const handleExport = () => {
setIsExportPopoverOpen(true);
const hasProject = !!editor.project.getActiveOrNull();
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
if (!open) {
editor.project.cancelExport();
editor.project.clearExportState();
}
setIsExportPopoverOpen(open);
};
const hasProject = !!editor.project.getActive();
return (
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
<Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}>
<PopoverTrigger asChild>
<button
type="button"
@ -50,12 +62,12 @@ export function ExportButton() {
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
)}
onClick={hasProject ? handleExport : undefined}
onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
disabled={!hasProject}
onKeyDown={(event) => {
if (hasProject && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
handleExport();
setIsExportPopoverOpen(true);
}
}}
>
@ -80,71 +92,49 @@ function ExportPopover({
}) {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { isExporting, progress, result: exportResult } =
editor.project.getExportState();
const [format, setFormat] = useState<ExportFormat>(
DEFAULT_EXPORT_OPTIONS.format,
);
const [quality, setQuality] = useState<ExportQuality>(
DEFAULT_EXPORT_OPTIONS.quality,
);
const [includeAudio, setIncludeAudio] = useState<boolean>(
DEFAULT_EXPORT_OPTIONS.includeAudio || true,
const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
);
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
const cancelRequestedRef = useRef(false);
const handleExport = async () => {
if (!activeProject) return;
cancelRequestedRef.current = false;
setIsExporting(true);
setProgress(0);
setExportResult(null);
const result = await editor.project.export({
options: {
format,
quality,
fps: activeProject.settings.fps,
includeAudio,
onProgress: ({ progress }) => setProgress(progress),
onCancel: () => cancelRequestedRef.current,
format,
quality,
fps: activeProject.settings.fps,
includeAudio: shouldIncludeAudio,
},
});
setIsExporting(false);
if (result.cancelled) {
setExportResult(null);
setProgress(0);
editor.project.clearExportState();
return;
}
setExportResult(result);
if (result.success && result.buffer) {
const mimeType = getExportMimeType({ format });
const extension = getExportFileExtension({ format });
const blob = new Blob([result.buffer], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${activeProject.metadata.name}${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadBuffer({
buffer: result.buffer,
filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
mimeType: getExportMimeType({ format }),
});
editor.project.clearExportState();
onOpenChange(false);
setExportResult(null);
setProgress(0);
}
};
const handleCancel = () => {
cancelRequestedRef.current = true;
editor.project.cancelExport();
};
return (
@ -166,8 +156,10 @@ function ExportPopover({
{!isExporting && (
<>
<div className="flex flex-col">
<Section collapsible defaultOpen={false} hasBorderTop={false}>
<SectionHeader title="Format" />
<Section collapsible defaultOpen={false} showTopBorder={false}>
<SectionHeader>
<SectionTitle>Format</SectionTitle>
</SectionHeader>
<SectionContent>
<RadioGroup
value={format}
@ -194,7 +186,9 @@ function ExportPopover({
</Section>
<Section collapsible defaultOpen={false}>
<SectionHeader title="Quality" />
<SectionHeader>
<SectionTitle>Quality</SectionTitle>
</SectionHeader>
<SectionContent>
<RadioGroup
value={quality}
@ -219,7 +213,7 @@ function ExportPopover({
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
Very High - Largest file size
Very high - Largest file size
</Label>
</div>
</RadioGroup>
@ -227,15 +221,17 @@ function ExportPopover({
</Section>
<Section collapsible defaultOpen={false}>
<SectionHeader title="Audio" />
<SectionHeader>
<SectionTitle>Audio</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex items-center space-x-2">
<Checkbox
id="include-audio"
checked={includeAudio}
onCheckedChange={(checked) =>
setIncludeAudio(!!checked)
}
checked={shouldIncludeAudio}
onCheckedChange={(checked) =>
setShouldIncludeAudio(!!checked)
}
/>
<Label htmlFor="include-audio">
Include audio in export
@ -256,15 +252,15 @@ function ExportPopover({
{isExporting && (
<div className="space-y-4 p-3">
<div className="flex flex-col">
<div className="flex items-center justify-between text-center">
<p className="text-muted-foreground mb-2 text-sm">
{Math.round(progress * 100)}%
</p>
<p className="text-muted-foreground mb-2 text-sm">100%</p>
</div>
<Progress value={progress * 100} className="w-full" />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-center">
<p className="text-muted-foreground text-sm">
{Math.round(progress * 100)}%
</p>
<p className="text-muted-foreground text-sm">100%</p>
</div>
<Progress value={progress * 100} className="w-full" />
</div>
<Button
variant="outline"
@ -282,14 +278,6 @@ function ExportPopover({
);
}
function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
}
function isExportQuality(value: string): value is ExportQuality {
return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
}
function ExportError({
error,
onRetry,
@ -306,7 +294,7 @@ function ExportError({
};
return (
<div className="space-y-4">
<div className="space-y-4 p-3">
<div className="flex flex-col gap-1.5">
<p className="text-destructive text-sm font-medium">Export failed</p>
<p className="text-muted-foreground text-xs">{error}</p>

View File

@ -31,7 +31,12 @@ import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media/processing";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import {
type MediaSortKey,
type MediaSortOrder,
type MediaViewMode,
useAssetsPanelStore,
} from "@/stores/assets-panel-store";
import type { MediaAsset } from "@/types/assets";
import { cn } from "@/utils/ui";
import {
@ -50,8 +55,15 @@ export function MediaView() {
const mediaFiles = editor.media.getAssets();
const activeProject = editor.project.getActive();
const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } =
useAssetsPanelStore();
const {
mediaViewMode,
setMediaViewMode,
highlightMediaId,
clearHighlight,
mediaSortBy,
mediaSortOrder,
setMediaSort,
} = useAssetsPanelStore();
const { highlightedId, registerElement } = useRevealItem(
highlightMediaId,
clearHighlight,
@ -59,10 +71,6 @@ export function MediaView() {
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
"name",
);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const processFiles = async ({ files }: { files: FileList }) => {
if (!files || files.length === 0) return;
@ -121,27 +129,12 @@ export function MediaView() {
});
};
const addElementAtTime = ({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}): boolean => {
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" },
});
return true;
const handleSort = ({ key }: { key: SortKey }) => {
if (mediaSortBy === key) {
setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc");
} else {
setMediaSort(key, "asc");
}
};
const filteredMediaItems = useMemo(() => {
@ -151,7 +144,7 @@ export function MediaView() {
let valueA: string | number;
let valueB: string | number;
switch (sortBy) {
switch (mediaSortBy) {
case "name":
valueA = a.name.toLowerCase();
valueB = b.name.toLowerCase();
@ -172,154 +165,13 @@ export function MediaView() {
return 0;
}
if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
if (valueA < valueB) return mediaSortOrder === "asc" ? -1 : 1;
if (valueA > valueB) return mediaSortOrder === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [mediaFiles, sortBy, sortOrder]);
const previewComponents = useMemo(() => {
const previews = new Map<string, React.ReactNode>();
filteredMediaItems.forEach((item) => {
previews.set(item.id, <MediaPreview item={item} />);
previews.set(
`compact-${item.id}`,
<MediaPreview item={item} variant="compact" />,
);
});
return previews;
}, [filteredMediaItems]);
const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
const renderCompactPreview = (item: MediaAsset) =>
previewComponents.get(`compact-${item.id}`);
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="ghost"
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"
>
<HugeiconsIcon icon={CloudUploadIcon} />
Import
</Button>
</div>
);
}, [mediaFiles, mediaSortBy, mediaSortOrder]);
return (
<>
@ -327,8 +179,18 @@ export function MediaView() {
<PanelView
title="Assets"
actions={mediaActions}
className={isDragOver ? "bg-accent/30" : ""}
actions={
<MediaActions
mediaViewMode={mediaViewMode}
setMediaViewMode={setMediaViewMode}
isProcessing={isProcessing}
sortBy={mediaSortBy}
sortOrder={mediaSortOrder}
onSort={handleSort}
onImport={openFilePicker}
/>
}
className={cn(isDragOver && "bg-accent/30")}
{...dragProps}
>
{isDragOver || filteredMediaItems.length === 0 ? (
@ -338,21 +200,11 @@ export function MediaView() {
progress={progress}
onClick={openFilePicker}
/>
) : mediaViewMode === "grid" ? (
<GridView
items={filteredMediaItems}
renderPreview={renderPreview}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
) : (
<ListView
<MediaItemList
items={filteredMediaItems}
renderPreview={renderCompactPreview}
mode={mediaViewMode}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
@ -362,6 +214,67 @@ export function MediaView() {
);
}
function MediaAssetDraggable({
item,
preview,
isHighlighted,
variant,
isRounded,
}: {
item: MediaAsset;
preview: React.ReactNode;
isHighlighted: boolean;
variant: "card" | "compact";
isRounded?: boolean;
}) {
const editor = useEditor();
const addElementAtTime = ({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}) => {
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" },
});
};
return (
<DraggableItem
name={item.name}
preview={preview}
dragData={{
id: item.id,
type: "media",
mediaType: item.type,
name: item.name,
...(item.type !== "audio" && {
targetElementTypes: ["video", "image"] as const,
}),
}}
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
addElementAtTime({ asset: item, startTime: currentTime })
}
variant={variant}
isRounded={isRounded}
isHighlighted={isHighlighted}
/>
);
}
function MediaItemWithContextMenu({
item,
children,
@ -387,55 +300,41 @@ function MediaItemWithContextMenu({
);
}
function GridView({
function MediaItemList({
items,
renderPreview,
mode,
onRemove,
onAddToTimeline,
highlightedId,
registerElement,
}: {
items: MediaAsset[];
renderPreview: (item: MediaAsset) => React.ReactNode;
mode: MediaViewMode;
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
onAddToTimeline: ({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}) => boolean;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
const isGrid = mode === "grid";
return (
<div
className="grid gap-2"
style={{
gridTemplateColumns: "repeat(auto-fill, 160px)",
}}
className={cn(isGrid ? "grid gap-2" : "flex flex-col gap-1")}
style={
isGrid ? { gridTemplateColumns: "repeat(auto-fill, 160px)" } : undefined
}
>
{items.map((item) => (
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
<div key={item.id} ref={(element) => registerElement(item.id, element)}>
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
<DraggableItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: "media",
mediaType: item.type,
name: item.name,
...(item.type !== "audio" && {
targetElementTypes: ["video", "image"] as const,
}),
}}
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
onAddToTimeline({ asset: item, startTime: currentTime })
<MediaAssetDraggable
item={item}
preview={
<MediaPreview
item={item}
variant={isGrid ? "grid" : "compact"}
/>
}
isRounded={false}
variant="card"
variant={isGrid ? "card" : "compact"}
isRounded={isGrid ? false : undefined}
isHighlighted={highlightedId === item.id}
/>
</MediaItemWithContextMenu>
@ -445,63 +344,11 @@ function GridView({
);
}
function ListView({
items,
renderPreview,
onRemove,
onAddToTimeline,
highlightedId,
registerElement,
}: {
items: MediaAsset[];
renderPreview: (item: MediaAsset) => React.ReactNode;
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
onAddToTimeline: ({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}) => boolean;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
return (
<div className="space-y-1">
{items.map((item) => (
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
<DraggableItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: "media",
mediaType: item.type,
name: item.name,
...(item.type !== "audio" && {
targetElementTypes: ["video", "image"] as const,
}),
}}
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
onAddToTimeline({ asset: item, startTime: currentTime })
}
variant="compact"
isHighlighted={highlightedId === item.id}
/>
</MediaItemWithContextMenu>
</div>
))}
</div>
);
}
const formatDuration = ({ duration }: { duration: number }) => {
export function formatDuration({ duration }: { duration: number }) {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
}
function MediaDurationBadge({ duration }: { duration?: number }) {
if (!duration) return null;
@ -619,6 +466,119 @@ function MediaPreview({
);
}
function MediaActions({
mediaViewMode,
setMediaViewMode,
isProcessing,
sortBy,
sortOrder,
onSort,
onImport,
}: {
mediaViewMode: MediaViewMode;
setMediaViewMode: (mode: MediaViewMode) => void;
isProcessing: boolean;
sortBy: MediaSortKey;
sortOrder: MediaSortOrder;
onSort: ({ key }: { key: MediaSortKey }) => void;
onImport: () => void;
}) {
return (
<div className="flex gap-1.5">
<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>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
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={onSort}
/>
<SortMenuItem
label="Type"
sortKey="type"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={onSort}
/>
<SortMenuItem
label="Duration"
sortKey="duration"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={onSort}
/>
<SortMenuItem
label="File size"
sortKey="size"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={onSort}
/>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by {sortBy} (
{sortOrder === "asc" ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
onClick={onImport}
disabled={isProcessing}
size="sm"
className="items-center justify-center gap-1.5"
>
<HugeiconsIcon icon={CloudUploadIcon} />
Import
</Button>
</div>
);
}
function SortMenuItem({
label,
sortKey,
@ -627,10 +587,10 @@ function SortMenuItem({
onSort,
}: {
label: string;
sortKey: "name" | "type" | "duration" | "size";
currentSortBy: string;
currentSortOrder: "asc" | "desc";
onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
sortKey: MediaSortKey;
currentSortBy: MediaSortKey;
currentSortOrder: MediaSortOrder;
onSort: ({ key }: { key: MediaSortKey }) => void;
}) {
const isActive = currentSortBy === sortKey;
const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";

View File

@ -16,6 +16,7 @@ import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/editor/panels/properties/section";
import { Label } from "@/components/ui/label";
import {
@ -23,25 +24,46 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useState } from "react";
const ORIGINAL_PRESET_VALUE = "original";
export function findPresetIndexByAspectRatio({
presets,
targetAspectRatio,
}: {
presets: Array<{ width: number; height: number }>;
targetAspectRatio: string;
}) {
for (let index = 0; index < presets.length; index++) {
const preset = presets[index];
const presetAspectRatio = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
if (presetAspectRatio === targetAspectRatio) {
return index;
}
}
return -1;
}
export function SettingsView() {
const [open, setOpen] = useState(false);
return (
<PanelView contentClassName="px-0" hideHeader>
<div className="flex flex-col">
<Section hasBorderTop={false}>
<Section showTopBorder={false}>
<SectionContent>
<ProjectInfoContent />
</SectionContent>
</Section>
<Popover open={open} onOpenChange={setOpen}>
<Popover>
<Section className="cursor-pointer">
<PopoverTrigger asChild>
<div>
<SectionHeader title="Background">
<div className="size-4 rounded-sm bg-red-500" />
<SectionHeader
trailing={<div className="size-4 rounded-sm bg-red-500" />}
>
<SectionTitle>Background</SectionTitle>
</SectionHeader>
</div>
</PopoverTrigger>
@ -60,26 +82,6 @@ function ProjectInfoContent() {
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;
@ -87,12 +89,11 @@ function ProjectInfoContent() {
presets: canvasPresets,
targetAspectRatio: currentAspectRatio,
});
const originalPresetValue = "original";
const selectedPresetValue =
presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE;
const handleAspectRatioChange = ({ value }: { value: string }) => {
if (value === originalPresetValue) {
if (value === ORIGINAL_PRESET_VALUE) {
const canvasSize = originalCanvasSize ?? currentCanvasSize;
editor.project.updateSettings({
settings: { canvasSize },
@ -106,7 +107,7 @@ function ProjectInfoContent() {
}
};
const handleFpsChange = (value: string) => {
const handleFpsChange = ({ value }: { value: string }) => {
const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } });
};
@ -129,7 +130,7 @@ function ProjectInfoContent() {
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
<SelectItem value={originalPresetValue}>Original</SelectItem>
<SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
@ -148,7 +149,7 @@ function ProjectInfoContent() {
<Label>Frame rate</Label>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange}
onValueChange={(value) => handleFpsChange({ value })}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Select a frame rate" />

View File

@ -56,7 +56,7 @@ export function TextEditOverlay({
}, [editor.timeline, trackId, elementId]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
({ event }: { event: React.KeyboardEvent }) => {
const { key } = event;
if (key === "Escape") {
event.preventDefault();
@ -98,10 +98,13 @@ export function TextEditOverlay({
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const letterSpacing = element.letterSpacing ?? 0;
const backgroundColor =
element.background.color === "transparent"
? "transparent"
: element.background.color;
const shouldShowBackground =
element.background.enabled &&
element.background.color &&
element.background.color !== "transparent";
const backgroundColor = shouldShowBackground
? element.background.color
: "transparent";
return (
<div
@ -134,13 +137,12 @@ export function TextEditOverlay({
backgroundColor,
minHeight: displayFontSize * lineHeight,
textDecoration: element.textDecoration ?? "none",
padding:
backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0,
minWidth: 1,
}}
onInput={handleInput}
onBlur={onCommit}
onKeyDown={handleKeyDown}
onKeyDown={(event) => handleKeyDown({ event })}
>
{element.content || ""}
</div>

View File

@ -23,7 +23,7 @@ const CORNERS: Corner[] = [
"bottom-right",
];
function getCornerPosition({
export function getCornerPosition({
bounds,
corner,
}: {
@ -47,7 +47,7 @@ function getCornerPosition({
};
}
function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
x: number;
y: number;
} {
@ -61,7 +61,7 @@ function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
};
}
function getOverlayContext({
export function getOverlayContext({
canvasRef,
containerRef,
}: {
@ -196,7 +196,7 @@ function BoundingBoxOutline({
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
border: "1px solid white",
border: "1px dashed white",
boxSizing: "border-box",
opacity: 0.75,
}}

View File

@ -1,5 +1,3 @@
import type { AudioElement } from "@/types/timeline";
export function AudioProperties({ _element }: { _element: AudioElement }) {
export function AudioProperties() {
return <div className="space-y-4 p-5">Audio properties</div>;
}

View File

@ -0,0 +1,211 @@
"use client";
import { useState } from "react";
import type { Effect } from "@/types/effects";
import type { VisualElement } from "@/types/timeline";
import { getEffect } from "@/lib/effects/registry";
import { useEditor } from "@/hooks/use-editor";
import { usePropertiesStore } from "@/stores/properties-store";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
SectionFields,
} from "./section";
import { EffectParamField } from "./effect-param-field";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowLeft01Icon,
Delete02Icon,
ViewIcon,
ViewOffSlashIcon,
} from "@hugeicons/core-free-icons";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/utils/ui";
export function ClipEffectsProperties({
element,
trackId,
}: {
element: VisualElement;
trackId: string;
}) {
const closeClipEffects = usePropertiesStore(
(state) => state.closeClipEffects,
);
const editor = useEditor();
const effects = element.effects ?? [];
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const handleDragStart = ({ index }: { index: number }) => {
setDragIndex(index);
};
const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => {
event.preventDefault();
if (index !== dropIndex) setDropIndex(index);
};
const handleDrop = ({ toIndex }: { toIndex: number }) => {
if (dragIndex !== null && dragIndex !== toIndex) {
editor.timeline.reorderClipEffects({
trackId,
elementId: element.id,
fromIndex: dragIndex,
toIndex,
});
}
setDragIndex(null);
setDropIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDropIndex(null);
};
return (
<div className="flex h-full flex-col">
<div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5">
<Button
variant="ghost"
size="icon"
onClick={closeClipEffects}
aria-label="Back to properties"
>
<HugeiconsIcon icon={ArrowLeft01Icon} />
</Button>
<span className="text-sm font-medium">Effects</span>
</div>
<ScrollArea className="flex-1 scrollbar-hidden">
{effects.map((effect, index) => (
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder
<div
key={effect.id}
draggable
onDragStart={() => handleDragStart({ index })}
onDragOver={(event) => handleDragOver({ event, index })}
onDrop={() => handleDrop({ toIndex: index })}
onDragEnd={handleDragEnd}
className={cn(
"group",
dragIndex === index && "opacity-40",
dropIndex === index &&
dragIndex !== null &&
dragIndex !== index &&
(index < dragIndex
? "border-t-2 border-primary"
: "border-b-2 border-primary"),
)}
>
<ClipEffectSection
effect={effect}
element={element}
trackId={trackId}
/>
</div>
))}
</ScrollArea>
</div>
);
}
function ClipEffectSection({
effect,
element,
trackId,
}: {
effect: Effect;
element: VisualElement;
trackId: string;
}) {
const editor = useEditor();
const definition = getEffect({ effectType: effect.type });
const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => {
const updatedEffects = (element.effects ?? []).map((existing) =>
existing.id !== effect.id
? existing
: { ...existing, params: { ...existing.params, [key]: value } },
);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { effects: updatedEffects },
},
],
});
};
const commitParam = () => editor.timeline.commitPreview();
const toggleEffect = () =>
editor.timeline.toggleClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
});
const removeEffect = () =>
editor.timeline.removeClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
});
return (
<Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}>
<SectionHeader
className="cursor-move"
trailing={
<>
<Button
variant={effect.enabled ? "secondary" : "ghost"}
size="icon"
aria-label={`Toggle ${definition.name}`}
onClick={toggleEffect}
>
<HugeiconsIcon
icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
/>
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Remove ${definition.name}`}
onClick={removeEffect}
>
<HugeiconsIcon icon={Delete02Icon} />
</Button>
</>
}
>
<SectionTitle
className={cn(!effect.enabled && "text-muted-foreground")}
>
{definition.name}
</SectionTitle>
</SectionHeader>
{effect.enabled && (
<SectionContent>
<SectionFields>
{definition.params.map((param) => (
<EffectParamField
key={param.key}
param={param}
value={effect.params[param.key] ?? param.default}
onPreview={previewParam({ key: param.key })}
onCommit={commitParam}
/>
))}
</SectionFields>
</SectionContent>
)}
</Section>
);
}

View File

@ -0,0 +1,152 @@
"use client";
import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects";
import { clamp } from "@/utils/math";
import { SectionField } from "./section";
import { Slider } from "@/components/ui/slider";
import { NumberField } from "@/components/ui/number-field";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { usePropertyDraft } from "./hooks/use-property-draft";
export function EffectParamField({
param,
value,
onPreview,
onCommit,
}: {
param: EffectParamDefinition;
value: number | string | boolean;
onPreview: (value: number | string | boolean) => void;
onCommit: () => void;
}) {
return (
<SectionField label={param.label}>
<EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} />
</SectionField>
);
}
function EffectParamInput({
param,
value,
onPreview,
onCommit,
}: {
param: EffectParamDefinition;
value: number | string | boolean;
onPreview: (value: number | string | boolean) => void;
onCommit: () => void;
}) {
if (param.type === "number") {
return (
<NumberParamField
param={param}
value={typeof value === "number" ? value : Number(value)}
onPreview={onPreview}
onCommit={onCommit}
/>
);
}
if (param.type === "boolean") {
return (
<Switch
checked={Boolean(value)}
onCheckedChange={(checked) => {
onPreview(checked);
onCommit();
}}
/>
);
}
if (param.type === "select") {
return (
<Select
value={String(value)}
onValueChange={(selected) => {
onPreview(selected);
onCommit();
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{param.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (param.type === "color") {
return (
<input
type="color"
className="h-8 w-full cursor-pointer rounded border"
value={String(value)}
onChange={(event) => onPreview(event.target.value)}
onBlur={onCommit}
/>
);
}
return null;
}
function NumberParamField({
param,
value,
onPreview,
onCommit,
}: {
param: NumberEffectParamDefinition;
value: number;
onPreview: (value: number) => void;
onCommit: () => void;
}) {
const { min, max, step } = param;
const draft = usePropertyDraft({
displayValue: String(value),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min, max });
},
onPreview,
onCommit,
});
return (
<div className="flex items-center gap-3">
<Slider
className="flex-1"
min={min}
max={max}
step={step}
value={[value]}
onValueChange={([newValue]) => onPreview(newValue)}
onValueCommit={onCommit}
/>
<NumberField
className="w-16 shrink-0"
value={draft.displayValue}
onFocus={draft.onFocus}
onChange={draft.onChange}
onBlur={draft.onBlur}
/>
</div>
);
}

View File

@ -1,78 +1,16 @@
"use client";
import type { EffectElement } from "@/types/timeline";
import type { EffectParamDefinition } from "@/types/effects";
import { getEffect } from "@/lib/effects/registry";
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section";
import { Slider } from "@/components/ui/slider";
import { NumberField } from "@/components/ui/number-field";
import { usePropertyDraft } from "./hooks/use-property-draft";
function EffectParamField({
param,
element,
trackId,
}: {
param: EffectParamDefinition;
element: EffectElement;
trackId: string;
}) {
const editor = useEditor();
const currentValue = Number(element.params[param.key] ?? param.default);
const min = param.min ?? 0;
const max = param.max ?? 100;
const step = param.step ?? 1;
const updateParam = (value: number) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { params: { ...element.params, [param.key]: value } },
},
],
});
const commitParam = () => editor.timeline.commitPreview();
const draft = usePropertyDraft({
displayValue: String(currentValue),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min, max });
},
onPreview: updateParam,
onCommit: commitParam,
});
return (
<SectionField label={param.label}>
<div className="flex items-center gap-3">
<Slider
className="flex-1"
min={min}
max={max}
step={step}
value={[currentValue]}
onValueChange={([value]) => updateParam(value)}
onValueCommit={commitParam}
/>
<NumberField
className="w-16 shrink-0"
value={draft.displayValue}
onFocus={draft.onFocus}
onChange={draft.onChange}
onBlur={draft.onBlur}
/>
</div>
</SectionField>
);
}
import {
Section,
SectionContent,
SectionHeader,
SectionFields,
SectionTitle,
} from "./section";
import { EffectParamField } from "./effect-param-field";
export function EffectProperties({
element,
@ -81,19 +19,36 @@ export function EffectProperties({
element: EffectElement;
trackId: string;
}) {
const editor = useEditor();
const definition = getEffect({ effectType: element.effectType });
const previewParam =
({ key }: { key: string }) =>
(value: number | string | boolean) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { params: { ...element.params, [key]: value } },
},
],
});
return (
<Section hasBorderTop={false}>
<SectionHeader title={definition.name} />
<Section showTopBorder={false}>
<SectionHeader>
<SectionTitle>{definition.name}</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
{definition.params.map((param) => (
<EffectParamField
key={param.key}
param={param}
element={element}
trackId={trackId}
value={element.params[param.key] ?? param.default}
onPreview={previewParam({ key: param.key })}
onCommit={() => editor.timeline.commitPreview()}
/>
))}
</SectionFields>

View File

@ -5,9 +5,12 @@ import { AudioProperties } from "./audio-properties";
import { VideoProperties } from "./video-properties";
import { TextProperties } from "./text-properties";
import { EffectProperties } from "./effect-properties";
import { ClipEffectsProperties } from "./clip-effects-properties";
import { EmptyView } from "./empty-view";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { usePropertiesStore } from "@/stores/properties-store";
import { isVisualElement } from "@/lib/timeline";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
function ElementProperties({
@ -21,7 +24,7 @@ function ElementProperties({
return <TextProperties element={element} trackId={track.id} />;
}
if (element.type === "audio") {
return <AudioProperties _element={element} />;
return <AudioProperties />;
}
if (
element.type === "video" ||
@ -39,16 +42,33 @@ function ElementProperties({
export function PropertiesPanel() {
const editor = useEditor();
const { selectedElements } = useElementSelection();
const clipEffectsTarget = usePropertiesStore(
(state) => state.clipEffectsTarget,
);
const clipEffectsTrack = clipEffectsTarget
? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId })
: null;
const clipEffectsElement = clipEffectsTrack?.elements.find(
(element) => element.id === clipEffectsTarget?.elementId,
);
const isShowingClipEffects =
clipEffectsTrack &&
clipEffectsElement &&
isVisualElement(clipEffectsElement);
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: selectedElements,
});
const hasSelection = selectedElements.length > 0;
return (
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
{hasSelection ? (
{isShowingClipEffects ? (
<ClipEffectsProperties
element={clipEffectsElement}
trackId={clipEffectsTrack.id}
/>
) : selectedElements.length > 0 ? (
<ScrollArea className="h-full scrollbar-hidden">
{elementsWithTracks.map(({ track, element }) => (
<ElementProperties

View File

@ -1,10 +1,12 @@
import { createContext, useContext, useState } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
const sectionExpandedCache = new Map<string, boolean>();
const mountedSectionKeys = new Set<string>();
interface SectionContext {
isOpen: boolean;
@ -24,8 +26,8 @@ interface SectionProps {
defaultOpen?: boolean;
sectionKey?: string;
className?: string;
hasBorderTop?: boolean;
hasBorderBottom?: boolean;
showTopBorder?: boolean;
showBottomBorder?: boolean;
}
export function Section({
@ -34,12 +36,21 @@ export function Section({
defaultOpen = true,
sectionKey,
className,
hasBorderTop = true,
hasBorderBottom = true,
showTopBorder = true,
showBottomBorder = true,
}: SectionProps) {
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
useEffect(() => {
if (!sectionKey) return;
if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) {
console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`);
}
mountedSectionKeys.add(sectionKey);
return () => { mountedSectionKeys.delete(sectionKey); };
}, [sectionKey]);
const toggle = () => {
const next = !isOpen;
setIsOpen(next);
@ -51,8 +62,8 @@ export function Section({
<div
className={cn(
"flex flex-col",
hasBorderTop && "border-t",
hasBorderBottom && "last:border-b",
showTopBorder && "border-t",
showBottomBorder && "last:border-b",
className,
)}
>
@ -63,15 +74,19 @@ export function Section({
}
interface SectionHeaderProps {
title: string;
children?: React.ReactNode;
trailing?: React.ReactNode;
leading?: React.ReactNode;
actions?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function SectionHeader({
title,
children,
trailing,
leading,
actions,
onClick,
className,
}: SectionHeaderProps) {
@ -79,57 +94,108 @@ export function SectionHeader({
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
const isInteractive = isCollapsible || !!onClick;
const handleClick = isCollapsible ? ctx?.toggle : onClick;
const content = (
const chevronIcon = isCollapsible ? (
<HugeiconsIcon
icon={ArrowDownIcon}
className={cn(
"size-4 shrink-0 transition-transform duration-200 ease-out",
isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground",
)}
/>
) : null;
const headerContent = (
<>
<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>
{leading}
<div className="min-w-0 flex-1">{children}</div>
{(trailing || chevronIcon) && (
<div className="flex items-center">
{trailing}
{chevronIcon && (
<Button
variant="ghost"
size="icon"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(event) => {
event.stopPropagation();
handleClick?.();
}}
>
{chevronIcon}
</Button>
)}
</div>
)}
{actions}
</>
);
const baseClassName = cn(
"flex w-full items-center justify-between h-11 px-3.5",
className,
);
if (!isInteractive) {
return (
<div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}>
{headerContent}
</div>
);
}
if (isInteractive) {
return (
// biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here
<div
role="button"
tabIndex={0}
className={cn(
"flex h-11 w-full cursor-pointer items-center gap-2 px-3.5",
className,
)}
onClick={handleClick}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }}
>
{headerContent}
</div>
);
}
export function SectionTitle({
children,
className,
onClick,
}: {
children: React.ReactNode;
className?: string;
onClick?: () => void;
}) {
const ctx = useSectionContext();
const isOpen = ctx?.isOpen ?? true;
if (onClick) {
return (
<button
type="button"
className={cn(baseClassName, "cursor-pointer text-left")}
onClick={(event) => {
handleClick?.();
event.currentTarget.blur();
}}
className={cn(
"cursor-pointer text-sm font-medium",
isOpen ? "text-foreground" : "text-muted-foreground",
className,
)}
onClick={onClick}
>
{content}
{children}
</button>
);
}
return <div className={baseClassName}>{content}</div>;
return (
<span
className={cn(
"text-sm font-medium",
isOpen ? "text-foreground" : "text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
export function SectionFields({

View File

@ -8,7 +8,7 @@ import {
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
import { Section, SectionContent, SectionField, SectionHeader } from "../section";
import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
import {
Select,
SelectContent,
@ -74,7 +74,7 @@ export function BlendingSection({
committedBlendModeRef.current = blendMode;
}
const previewBlendMode = (value: BlendMode) =>
const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
@ -123,7 +123,7 @@ export function BlendingSection({
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader title="Blending" />
<SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
<SectionField label="Opacity" className="w-1/2">
@ -175,7 +175,7 @@ export function BlendingSection({
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode(option.value as BlendMode)
previewBlendMode({ value: option.value as BlendMode })
}
>
{option.label}

View File

@ -9,6 +9,7 @@ import {
SectionField,
SectionFields,
SectionHeader,
SectionTitle,
} from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
@ -24,12 +25,12 @@ import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
import { KeyframeToggle } from "../keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
function parseNumericInput({ input }: { input: string }): number | null {
export function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
function isPropertyAtDefault({
export function isPropertyAtDefault({
hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue,
@ -55,9 +56,11 @@ function isPropertyAtDefault({
export function TransformSection({
element,
trackId,
showTopBorder = true,
}: {
element: VisualElement;
trackId: string;
showTopBorder?: boolean;
}) {
const editor = useEditor();
const [isScaleLocked, setIsScaleLocked] = useState(false);
@ -239,8 +242,12 @@ export function TransformSection({
};
return (
<Section collapsible sectionKey={`${element.type}:transform`}>
<SectionHeader title="Transform" />
<Section
collapsible
sectionKey={`${element.type}:transform`}
showTopBorder={showTopBorder}
>
<SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
<SectionContent>
<SectionFields>
<SectionField
@ -261,11 +268,11 @@ export function TransformSection({
<NumberField icon="H" {...scaleFieldProps} />
</>
) : (
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldProps}
className="flex-1"
/>
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldProps}
className="flex-1"
/>
)}
<Button
type="button"

View File

@ -3,13 +3,23 @@ import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/types/timeline";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section";
import {
Section,
SectionContent,
SectionField,
SectionFields,
SectionHeader,
SectionTitle,
} from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_BACKGROUND,
@ -20,30 +30,13 @@ import {
import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections";
import { HugeiconsIcon } from "@hugeicons/react";
import { TextFontIcon } from "@hugeicons/core-free-icons";
import {
TextFontIcon,
ViewIcon,
ViewOffSlashIcon,
} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
function createOffsetConverter({
defaultValue,
scale = 1,
min,
}: {
defaultValue: number;
scale?: number;
min?: number;
}) {
return {
toDisplay: (value: number) => Math.round((value - defaultValue) * scale),
fromDisplay: (display: number) => {
const stored = defaultValue + display / scale;
return min !== undefined ? Math.max(min, stored) : stored;
},
};
}
const lineHeightConverter = createOffsetConverter({ defaultValue: DEFAULT_LINE_HEIGHT, scale: 10 });
const paddingXConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, min: 0 });
const paddingYConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, min: 0 });
import { cn } from "@/utils/ui";
export function TextProperties({
element,
@ -86,8 +79,8 @@ function ContentSection({
});
return (
<Section collapsible sectionKey="text:content" hasBorderTop={false}>
<SectionHeader title="Content" />
<Section collapsible sectionKey="text:content" showTopBorder={false}>
<SectionHeader><SectionTitle>Content</SectionTitle></SectionHeader>
<SectionContent>
<Textarea
placeholder="Name"
@ -129,71 +122,71 @@ function TypographySection({
return (
<Section collapsible sectionKey="text:typography">
<SectionHeader title="Typography" />
<SectionContent>
<SectionFields>
<SectionField label="Font">
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value) =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontFamily: value },
},
],
})
}
/>
</SectionField>
<SectionField label="Size">
<NumberField
value={fontSize.displayValue}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
onFocus={fontSize.onFocus}
onChange={fontSize.onChange}
onBlur={fontSize.onBlur}
onScrub={fontSize.scrubTo}
onScrubEnd={fontSize.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
},
],
})
}
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
icon={<HugeiconsIcon icon={TextFontIcon} />}
/>
</SectionField>
<SectionField label="Color">
<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()}
/>
</SectionField>
</SectionFields>
</SectionContent>
<SectionHeader><SectionTitle>Typography</SectionTitle></SectionHeader>
<SectionContent>
<SectionFields>
<SectionField label="Font">
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value) =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontFamily: value },
},
],
})
}
/>
</SectionField>
<SectionField label="Size">
<NumberField
value={fontSize.displayValue}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
onFocus={fontSize.onFocus}
onChange={fontSize.onChange}
onBlur={fontSize.onBlur}
onScrub={fontSize.scrubTo}
onScrubEnd={fontSize.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
},
],
})
}
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
icon={<HugeiconsIcon icon={TextFontIcon} />}
/>
</SectionField>
<SectionField label="Color">
<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()}
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}
@ -208,72 +201,98 @@ function SpacingSection({
const editor = useEditor();
const letterSpacing = usePropertyDraft({
displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(),
displayValue: Math.round(
element.letterSpacing ?? DEFAULT_LETTER_SPACING,
).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [{ trackId, elementId: element.id, updates: { letterSpacing: value } }],
updates: [
{ trackId, elementId: element.id, updates: { letterSpacing: value } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const lineHeight = usePropertyDraft({
displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(),
displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed));
return Number.isNaN(parsed)
? null
: Math.max(0.1, Math.round(parsed * 10) / 10);
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [{ trackId, elementId: element.id, updates: { lineHeight: value } }],
updates: [
{ trackId, elementId: element.id, updates: { lineHeight: value } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:spacing" hasBorderBottom={false}>
<SectionHeader title="Spacing" />
<SectionContent>
<div className="flex items-start gap-2">
<SectionField label="Letter spacing" className="w-1/2">
<NumberField
value={letterSpacing.displayValue}
onFocus={letterSpacing.onFocus}
onChange={letterSpacing.onChange}
onBlur={letterSpacing.onBlur}
onScrub={letterSpacing.scrubTo}
onScrubEnd={letterSpacing.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }],
})
}
isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING}
icon={<OcTextWidthIcon size={14} />}
/>
</SectionField>
<SectionField label="Line height" className="w-1/2">
<NumberField
value={lineHeight.displayValue}
onFocus={lineHeight.onFocus}
onChange={lineHeight.onChange}
onBlur={lineHeight.onBlur}
onScrub={lineHeight.scrubTo}
onScrubEnd={lineHeight.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }],
})
}
isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT}
icon={<OcTextHeightIcon size={14} />}
/>
</SectionField>
</div>
</SectionContent>
<Section collapsible sectionKey="text:spacing" showBottomBorder={false}>
<SectionHeader><SectionTitle>Spacing</SectionTitle></SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
<SectionField label="Letter spacing" className="w-1/2">
<NumberField
value={letterSpacing.displayValue}
onFocus={letterSpacing.onFocus}
onChange={letterSpacing.onChange}
onBlur={letterSpacing.onBlur}
onScrub={letterSpacing.scrubTo}
onScrubEnd={letterSpacing.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { letterSpacing: DEFAULT_LETTER_SPACING },
},
],
})
}
isDefault={
(element.letterSpacing ?? DEFAULT_LETTER_SPACING) ===
DEFAULT_LETTER_SPACING
}
icon={<OcTextWidthIcon size={14} />}
/>
</SectionField>
<SectionField label="Line height" className="w-1/2">
<NumberField
value={lineHeight.displayValue}
onFocus={lineHeight.onFocus}
onChange={lineHeight.onChange}
onBlur={lineHeight.onBlur}
onScrub={lineHeight.scrubTo}
onScrubEnd={lineHeight.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { lineHeight: DEFAULT_LINE_HEIGHT },
},
],
})
}
isDefault={
(element.lineHeight ?? DEFAULT_LINE_HEIGHT) ===
DEFAULT_LINE_HEIGHT
}
icon={<OcTextHeightIcon size={14} />}
/>
</SectionField>
</div>
</SectionContent>
</Section>
);
}
@ -289,10 +308,21 @@ function BackgroundSection({
const lastSelectedColor = useRef(DEFAULT_COLOR);
const cornerRadius = usePropertyDraft({
displayValue: Math.round(element.background.cornerRadius ?? 0).toString(),
displayValue: Math.round(
clamp({
value: element.background.cornerRadius ?? 0,
min: CORNER_RADIUS_MIN,
max: CORNER_RADIUS_MAX,
}),
).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
if (Number.isNaN(parsed)) return null;
return clamp({
value: Math.round(parsed),
min: CORNER_RADIUS_MIN,
max: CORNER_RADIUS_MAX,
});
},
onPreview: (value) =>
editor.timeline.previewElements({
@ -310,10 +340,12 @@ function BackgroundSection({
});
const paddingX = usePropertyDraft({
displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(),
displayValue: Math.round(
element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed));
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
@ -329,10 +361,12 @@ function BackgroundSection({
});
const paddingY = usePropertyDraft({
displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(),
displayValue: Math.round(
element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed));
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
@ -385,14 +419,63 @@ function BackgroundSection({
onCommit: () => editor.timeline.commitPreview(),
});
const toggleBackgroundEnabled = () => {
const enabled = !element.background.enabled;
const color =
enabled && element.background.color === "transparent"
? lastSelectedColor.current
: element.background.color;
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: {
...element.background,
enabled,
color,
},
},
},
],
});
};
return (
<Section collapsible sectionKey="text:background">
<SectionHeader title="Background" />
<SectionContent>
<Section
collapsible
defaultOpen={element.background.enabled}
sectionKey="text:background"
>
<SectionHeader
trailing={
<Button
variant="ghost"
size="icon"
onClick={(event) => {
event.stopPropagation();
toggleBackgroundEnabled();
}}
>
<HugeiconsIcon
icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon}
/>
</Button>
}
>
<SectionTitle>Background</SectionTitle>
</SectionHeader>
<SectionContent
className={cn(
!element.background.enabled && "pointer-events-none opacity-50",
)}
>
<SectionFields>
<SectionField label="Color">
<ColorPicker
value={
!element.background.enabled ||
element.background.color === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.background.color.replace("#", "")
@ -415,19 +498,14 @@ function BackgroundSection({
});
}}
onChangeEnd={() => editor.timeline.commitPreview()}
className={
element.background.color === "transparent"
? "pointer-events-none opacity-50"
: ""
}
/>
</SectionField>
<div className="flex items-start gap-2">
<SectionField label="Width" className="w-1/2">
<NumberField
icon="W"
value={paddingX.displayValue}
min={0}
<SectionField label="Width" className="w-1/2">
<NumberField
icon="W"
value={paddingX.displayValue}
min={0}
onFocus={paddingX.onFocus}
onChange={paddingX.onChange}
onBlur={paddingX.onBlur}
@ -450,16 +528,17 @@ function BackgroundSection({
})
}
isDefault={
(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) ===
(element.background.paddingX ??
DEFAULT_TEXT_BACKGROUND.paddingX) ===
DEFAULT_TEXT_BACKGROUND.paddingX
}
/>
</SectionField>
<SectionField label="Height" className="w-1/2">
<NumberField
icon="H"
value={paddingY.displayValue}
min={0}
<SectionField label="Height" className="w-1/2">
<NumberField
icon="H"
value={paddingY.displayValue}
min={0}
onFocus={paddingY.onFocus}
onChange={paddingY.onChange}
onBlur={paddingY.onBlur}
@ -482,17 +561,18 @@ function BackgroundSection({
})
}
isDefault={
(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) ===
(element.background.paddingY ??
DEFAULT_TEXT_BACKGROUND.paddingY) ===
DEFAULT_TEXT_BACKGROUND.paddingY
}
/>
</SectionField>
</div>
<div className="flex items-start gap-2">
<SectionField label="X-offset" className="w-1/2">
<NumberField
icon="X"
value={offsetX.displayValue}
<SectionField label="X-offset" className="w-1/2">
<NumberField
icon="X"
value={offsetX.displayValue}
onFocus={offsetX.onFocus}
onChange={offsetX.onChange}
onBlur={offsetX.onBlur}
@ -505,19 +585,19 @@ function BackgroundSection({
trackId,
elementId: element.id,
updates: {
background: { ...element.background, offsetX: 0 },
},
background: { ...element.background, offsetX: DEFAULT_TEXT_BACKGROUND.offsetX },
},
],
})
}
isDefault={(element.background.offsetX ?? 0) === 0}
},
],
})
}
isDefault={(element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX) === DEFAULT_TEXT_BACKGROUND.offsetX}
/>
</SectionField>
<SectionField label="Y-offset" className="w-1/2">
<NumberField
icon="Y"
value={offsetY.displayValue}
<SectionField label="Y-offset" className="w-1/2">
<NumberField
icon="Y"
value={offsetY.displayValue}
onFocus={offsetY.onFocus}
onChange={offsetY.onChange}
onBlur={offsetY.onBlur}
@ -530,21 +610,22 @@ function BackgroundSection({
trackId,
elementId: element.id,
updates: {
background: { ...element.background, offsetY: 0 },
},
background: { ...element.background, offsetY: DEFAULT_TEXT_BACKGROUND.offsetY },
},
],
})
}
isDefault={(element.background.offsetY ?? 0) === 0}
},
],
})
}
isDefault={(element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY) === DEFAULT_TEXT_BACKGROUND.offsetY}
/>
</SectionField>
</div>
<SectionField label="Corner Radius">
<NumberField
icon="R"
value={cornerRadius.displayValue}
min={0}
<SectionField label="Corner radius">
<NumberField
icon="R"
value={cornerRadius.displayValue}
min={CORNER_RADIUS_MIN}
max={CORNER_RADIUS_MAX}
onFocus={cornerRadius.onFocus}
onChange={cornerRadius.onChange}
onBlur={cornerRadius.onBlur}
@ -557,16 +638,18 @@ function BackgroundSection({
trackId,
elementId: element.id,
updates: {
background: {
...element.background,
cornerRadius: 0,
},
background: {
...element.background,
cornerRadius: CORNER_RADIUS_MIN,
},
},
},
],
})
}
isDefault={(element.background.cornerRadius ?? 0) === 0}
isDefault={
(element.background.cornerRadius ?? 0) === CORNER_RADIUS_MIN
}
/>
</SectionField>
</SectionFields>

View File

@ -1,4 +1,8 @@
import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline";
import type {
ImageElement,
StickerElement,
VideoElement,
} from "@/types/timeline";
import { BlendingSection, TransformSection } from "./sections";
export function VideoProperties({
@ -10,7 +14,11 @@ export function VideoProperties({
}) {
return (
<div className="flex h-full flex-col">
<TransformSection element={element} trackId={trackId} />
<TransformSection
element={element}
trackId={trackId}
showTopBorder={false}
/>
<BlendingSection element={element} trackId={trackId} />
</div>
);

View File

@ -21,7 +21,7 @@ export function DragLine({
return (
<div
className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
style={{ top: `${lineTop}px` }}
/>
);

View File

@ -55,16 +55,18 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
const FALLBACK_CONTAINER_WIDTH = 1000;
export function Timeline() {
const tracksContainerHeight = { min: 0, max: 800 };
const { snappingEnabled } = useTimelineStore();
const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
const { clearElementSelection, setElementSelection } = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
const tracks = timeline.getTracks();
const seek = (time: number) => editor.playback.seek({ time });
// refs
const timelineRef = useRef<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
@ -74,7 +76,6 @@ export function Timeline() {
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
// state
const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
@ -151,6 +152,7 @@ export function Timeline() {
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
headerRef: timelineHeaderRef,
tracksScrollRef,
zoomLevel,
});
@ -169,7 +171,7 @@ export function Timeline() {
zoomLevel,
});
const containerWidth = tracksContainerRef.current?.clientWidth || 1000;
const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({
@ -422,7 +424,20 @@ export function Timeline() {
{tracks.length === 0 ? (
<div />
) : (
tracks.map((track, index) => (
[...tracks]
.map((track, index) => ({ track, index }))
.sort((a, b) => {
const aHasDragged = a.track.elements.some(
(element) => element.id === dragState.elementId,
);
const bHasDragged = b.track.elements.some(
(element) => element.id === dragState.elementId,
);
if (aHasDragged) return 1;
if (bHasDragged) return -1;
return 0;
})
.map(({ track, index }) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
@ -465,50 +480,50 @@ export function Timeline() {
<ContextMenuContent className="w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
onClick={(e) => {
e.stopPropagation();
invokeAction("paste-copied");
}}
>
Paste elements
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
timeline.toggleTrackMute({
trackId: track.id,
});
}}
>
<HugeiconsIcon icon={VolumeHighIcon} />
<span>
{canTracktHaveAudio(track) && track.muted
? "Unmute track"
: "Mute track"}
</span>
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
timeline.toggleTrackVisibility({
trackId: track.id,
});
}}
>
<HugeiconsIcon icon={ViewIcon} />
<span>
{canTrackBeHidden(track) && track.hidden
? "Show track"
: "Hide track"}
</span>
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
timeline.removeTrack({
trackId: track.id,
});
}}
onClick={(event) => {
event.stopPropagation();
invokeAction("paste-copied");
}}
>
Paste elements
</ContextMenuItem>
<ContextMenuItem
onClick={(event) => {
event.stopPropagation();
timeline.toggleTrackMute({
trackId: track.id,
});
}}
>
<HugeiconsIcon icon={VolumeHighIcon} />
<span>
{canTracktHaveAudio(track) && track.muted
? "Unmute track"
: "Mute track"}
</span>
</ContextMenuItem>
<ContextMenuItem
onClick={(event) => {
event.stopPropagation();
timeline.toggleTrackVisibility({
trackId: track.id,
});
}}
>
<HugeiconsIcon icon={ViewIcon} />
<span>
{canTrackBeHidden(track) && track.hidden
? "Show track"
: "Hide track"}
</span>
</ContextMenuItem>
<ContextMenuItem
onClick={(event) => {
event.stopPropagation();
timeline.removeTrack({
trackId: track.id,
});
}}
variant="destructive"
>
<HugeiconsIcon icon={Delete02Icon} />

View File

@ -4,6 +4,10 @@ import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import AudioWaveform from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import {
useKeyframeDrag,
type KeyframeDragState,
} from "@/hooks/timeline/element/use-keyframe-drag";
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation";
@ -26,6 +30,7 @@ import {
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
VisualElement,
ElementDragState,
} from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
@ -53,8 +58,11 @@ import { uppercase } from "@/utils/string";
import type { ComponentProps, ReactNode } from "react";
import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation";
import { cn } from "@/utils/ui";
import { Button } from "@/components/ui/button";
import { usePropertiesStore } from "@/stores/properties-store";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5;
interface KeyframeIndicator {
time: number;
@ -62,7 +70,7 @@ interface KeyframeIndicator {
keyframes: SelectedKeyframeRef[];
}
function buildKeyframeIndicator({
export function buildKeyframeIndicator({
keyframe,
trackId,
elementId,
@ -98,7 +106,7 @@ function buildKeyframeIndicator({
};
}
function getKeyframeIndicators({
export function getKeyframeIndicators({
keyframes,
trackId,
elementId,
@ -145,8 +153,8 @@ function getKeyframeIndicators({
return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
}
function getDisplayShortcut(action: TAction) {
const { defaultShortcuts } = getActionDefinition(action);
export function getDisplayShortcut({ action }: { action: TAction }) {
const { defaultShortcuts } = getActionDefinition({ action });
if (!defaultShortcuts?.length) {
return "";
}
@ -164,10 +172,13 @@ interface TimelineElementProps {
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onElementMouseDown: (
e: React.MouseEvent,
event: React.MouseEvent,
element: TimelineElementType,
) => void;
onElementClick: (
event: React.MouseEvent,
element: TimelineElementType,
) => void;
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
dragState: ElementDragState;
isDropTarget?: boolean;
}
@ -188,12 +199,12 @@ export function TimelineElement({
const { selectedElements } = useElementSelection();
const { requestRevealMedia } = useAssetsPanelStore();
const mediaAssets = editor.media.getAssets();
let mediaAsset: MediaAsset | null = null;
if (hasMediaId(element)) {
mediaAsset =
mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
null;
}
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
@ -242,6 +253,13 @@ export function TimelineElement({
elementWidth,
})
: [];
const {
keyframeDragState,
handleKeyframeMouseDown,
handleKeyframeClick,
getVisualOffsetPx,
} = useKeyframeDrag({ zoomLevel, element });
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
event.stopPropagation();
if (hasMediaId(element)) {
@ -255,7 +273,7 @@ export function TimelineElement({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`absolute top-0 h-full select-none`}
className="absolute top-0 h-full select-none"
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
@ -269,15 +287,24 @@ export function TimelineElement({
element={element}
track={track}
isSelected={isSelected}
hasAudio={hasAudio}
isMuted={isMuted}
mediaAssets={mediaAssets}
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart}
isDropTarget={isDropTarget}
/>
{isSelected && <KeyframeIndicators indicators={keyframeIndicators} />}
{isSelected && (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<KeyframeIndicators
indicators={keyframeIndicators}
dragState={keyframeDragState}
displayedStartTime={displayedStartTime}
elementLeft={elementLeft}
onKeyframeMouseDown={handleKeyframeMouseDown}
onKeyframeClick={handleKeyframeClick}
getVisualOffsetPx={getVisualOffsetPx}
/>
</div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
@ -344,9 +371,6 @@ function ElementInner({
element,
track,
isSelected,
hasAudio,
isMuted,
mediaAssets,
onElementClick,
onElementMouseDown,
handleResizeStart,
@ -355,12 +379,12 @@ function ElementInner({
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
hasAudio: boolean;
isMuted: boolean;
mediaAssets: MediaAsset[];
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
onElementClick: (
event: React.MouseEvent,
element: TimelineElementType,
) => void;
onElementMouseDown: (
e: React.MouseEvent,
event: React.MouseEvent,
element: TimelineElementType,
) => void;
handleResizeStart: (params: {
@ -374,47 +398,56 @@ function ElementInner({
(canElementBeHidden(element) && element.hidden) || isDropTarget
? "opacity-50"
: "";
const closeClipEffects = usePropertiesStore(
(state) => state.closeClipEffects,
);
return (
<div
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
{
type: track.type,
},
)} ${opacityClass}`}
className="relative h-full cursor-pointer"
style={{ marginInline: ELEMENT_RING_WIDTH_PX }}
>
<button
type="button"
className="absolute inset-0 size-full cursor-pointer"
onClick={(e) => onElementClick(e, element)}
onMouseDown={(e) => onElementMouseDown(e, element)}
<div
className={cn(
"absolute inset-0 overflow-hidden rounded-sm",
getTrackClasses({ type: track.type }),
opacityClass,
)}
style={
isSelected
? {
boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`,
}
: undefined
}
>
<div className="absolute inset-0 flex h-full items-center">
<ElementContent
element={element}
track={track}
isSelected={isSelected}
mediaAssets={mediaAssets}
<button
type="button"
className="absolute inset-0 size-full cursor-pointer flex flex-col"
onClick={(event) => {
closeClipEffects();
onElementClick(event, element);
}}
onMouseDown={(event) => onElementMouseDown(event, element)}
>
<div className="flex flex-1 min-h-0 items-center overflow-hidden">
<ElementContent
element={element}
track={track}
isSelected={isSelected}
/>
</div>
</button>
</div>
{element.type !== "audio" && element.type !== "effect" && (
<div className="sticky left-1 z-10 mt-1 ml-1 w-fit">
<EffectsButton
element={element as VisualElement}
trackId={track.id}
/>
</div>
{(hasAudio
? isMuted
: canElementBeHidden(element) && element.hidden) && (
<div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
{hasAudio ? (
<HugeiconsIcon
icon={VolumeHighIcon}
className="size-6 text-white"
/>
) : (
<HugeiconsIcon
icon={VolumeOffIcon}
className="size-6 text-white"
/>
)}
</div>
)}
</button>
)}
{isSelected && (
<>
@ -451,68 +484,82 @@ function ResizeHandle({
return (
<button
type="button"
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"}`}
className={cn(
"absolute top-0 bottom-0 w-2",
isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
)}
onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
onClick={(event) => event.stopPropagation()}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
>
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
</button>
></button>
);
}
function KeyframeIndicators({
indicators,
dragState,
displayedStartTime,
elementLeft,
onKeyframeMouseDown,
onKeyframeClick,
getVisualOffsetPx,
}: {
indicators: KeyframeIndicator[];
dragState: KeyframeDragState;
displayedStartTime: number;
elementLeft: number;
onKeyframeMouseDown: (params: {
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
}) => void;
onKeyframeClick: (params: {
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
}) => void;
getVisualOffsetPx: (params: {
indicatorTime: number;
indicatorOffsetPx: number;
isBeingDragged: boolean;
displayedStartTime: number;
elementLeft: number;
}) => number;
}) {
const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } =
useKeyframeSelection();
const { isKeyframeSelected } = useKeyframeSelection();
const orderedKeyframes = indicators.flatMap(
(indicator) => indicator.keyframes,
);
const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => {
event.preventDefault();
event.stopPropagation();
};
const handleKeyframeClick = ({
event,
keyframes,
}: {
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
}) => {
event.stopPropagation();
if (event.shiftKey) {
selectKeyframeRange({
orderedKeyframes,
targetKeyframes: keyframes,
isAdditive: event.metaKey || event.ctrlKey,
});
return;
}
toggleKeyframeSelection({
keyframes,
isMultiKey: event.metaKey || event.ctrlKey,
});
};
return indicators.map((indicator) => {
const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
isKeyframeSelected({ keyframe }),
);
const isBeingDragged = indicator.keyframes.some((kf) =>
dragState.draggingKeyframeIds.has(kf.keyframeId),
);
const visualOffsetPx = getVisualOffsetPx({
indicatorTime: indicator.time,
indicatorOffsetPx: indicator.offsetPx,
isBeingDragged,
displayedStartTime,
elementLeft,
});
return (
<button
key={indicator.time}
type="button"
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer"
style={{ left: indicator.offsetPx }}
onMouseDown={(event) => handleKeyframeMouseDown({ event })}
className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab"
style={{ left: visualOffsetPx }}
onMouseDown={(event) =>
onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
}
onClick={(event) =>
handleKeyframeClick({ event, keyframes: indicator.keyframes })
onKeyframeClick({
event,
keyframes: indicator.keyframes,
orderedKeyframes,
})
}
aria-label="Select keyframe"
>
@ -533,10 +580,82 @@ interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
}
interface ElementContentRendererProps extends ElementContentProps {
mediaAssets: MediaAsset[];
}
type ElementContentRenderer = (props: ElementContentProps) => ReactNode;
type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode;
export function renderTiledMedia({
element,
imageUrl,
track,
}: {
element: VisualElement;
imageUrl: string | undefined;
track: ElementContentProps["track"];
}): ReactNode {
if (!imageUrl) {
return (
<span className="text-foreground/80 truncate text-xs">
{element.name}
</span>
);
}
const trackHeight = getTrackHeight({ type: track.type });
const tileWidth = trackHeight * (16 / 9);
return (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${imageUrl})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
/>
);
}
function EffectsButton({
element,
trackId,
className,
}: {
element: VisualElement;
trackId: string;
className?: string;
}) {
const openClipEffects = usePropertiesStore((state) => state.openClipEffects);
const { selectElement } = useElementSelection();
if (!element.effects?.length) {
return null;
}
const handleClick = (event: React.MouseEvent) => {
event.stopPropagation();
selectElement({ elementId: element.id, trackId });
openClipEffects({ elementId: element.id, trackId });
};
return (
<Button
variant="text"
size="icon"
className={cn("rounded-sm !size-5 bg-background/50", className)}
onClick={handleClick}
onMouseDown={(event) => event.stopPropagation()}
>
<HugeiconsIcon icon={MagicWand05Icon} />
</Button>
);
}
const ELEMENT_CONTENT_RENDERERS: Record<
TimelineElementType["type"],
@ -548,7 +667,7 @@ const ELEMENT_CONTENT_RENDERERS: Record<
{ type: "text" }
>;
return (
<div className="flex size-full items-center justify-start pl-3">
<div className="flex size-full items-center justify-start pl-2">
<span className="truncate text-xs text-white">
{textElement.content}
</span>
@ -557,8 +676,11 @@ const ELEMENT_CONTENT_RENDERERS: Record<
},
effect: ({ element }) => (
<div className="flex size-full items-center justify-start gap-1 pl-2">
<HugeiconsIcon icon={MagicWand05Icon} className="size-4 shrink-0 text-white" />
<span className="truncate text-xs text-white">{element.name}</span>
<HugeiconsIcon
icon={MagicWand05Icon}
className="size-4 shrink-0 text-white"
/>
<span className="truncate text-xs text-white ml-1">{element.name}</span>
</div>
),
sticker: ({ element }) => {
@ -618,7 +740,7 @@ const ELEMENT_CONTENT_RENDERERS: Record<
</span>
);
},
video: ({ element, track, isSelected, mediaAssets }) => {
video: ({ element, track, mediaAssets }) => {
const videoElement = element as Extract<
TimelineElementType,
{ type: "video" }
@ -626,48 +748,13 @@ const ELEMENT_CONTENT_RENDERERS: Record<
const mediaAsset = mediaAssets.find(
(asset) => asset.id === videoElement.mediaId,
);
if (!mediaAsset) {
return (
<span className="text-foreground/80 truncate text-xs">
{videoElement.name}
</span>
);
}
if (mediaAsset.thumbnailUrl) {
const trackHeight = getTrackHeight({ type: track.type });
const tileWidth = trackHeight * (16 / 9);
return (
<div className="flex size-full items-center justify-center">
<div
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
>
<div
className="absolute right-0 left-0"
style={{
backgroundImage: `url(${mediaAsset.thumbnailUrl})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
top: isSelected ? "0.25rem" : "0rem",
bottom: isSelected ? "0.25rem" : "0rem",
}}
/>
</div>
</div>
);
}
return (
<span className="text-foreground/80 truncate text-xs">
{videoElement.name}
</span>
);
return renderTiledMedia({
element: videoElement,
imageUrl: mediaAsset?.thumbnailUrl,
track,
});
},
image: ({ element, track, isSelected, mediaAssets }) => {
image: ({ element, track, mediaAssets }) => {
const imageElement = element as Extract<
TimelineElementType,
{ type: "image" }
@ -675,44 +762,27 @@ const ELEMENT_CONTENT_RENDERERS: Record<
const mediaAsset = mediaAssets.find(
(asset) => asset.id === imageElement.mediaId,
);
if (!mediaAsset?.url) {
return (
<span className="text-foreground/80 truncate text-xs">
{imageElement.name}
</span>
);
}
const trackHeight = getTrackHeight({ type: track.type });
const tileWidth = trackHeight * (16 / 9);
return (
<div className="flex size-full items-center justify-center">
<div
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
>
<div
className="absolute right-0 left-0"
style={{
backgroundImage: `url(${mediaAsset.url})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
top: isSelected ? "0.25rem" : "0rem",
bottom: isSelected ? "0.25rem" : "0rem",
}}
/>
</div>
</div>
);
return renderTiledMedia({
element: imageElement,
imageUrl: mediaAsset?.url,
track,
});
},
};
function ElementContent(props: ElementContentProps) {
const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type];
return <>{renderer(props)}</>;
function ElementContent({ element, track, isSelected }: ElementContentProps) {
const editor = useEditor();
const renderer = ELEMENT_CONTENT_RENDERERS[element.type];
return (
<>
{renderer({
element,
track,
isSelected,
mediaAssets: editor.media.getAssets(),
})}
</>
);
}
function CopyMenuItem() {
@ -740,9 +810,9 @@ function MuteMenuItem({
return <HugeiconsIcon icon={VolumeMute02Icon} />;
}
return isMuted ? (
<HugeiconsIcon icon={VolumeHighIcon} />
) : (
<HugeiconsIcon icon={VolumeOffIcon} />
) : (
<HugeiconsIcon icon={VolumeHighIcon} />
);
};
@ -823,7 +893,7 @@ function ActionMenuItem({
event.stopPropagation();
invokeAction(action);
}}
textRight={getDisplayShortcut(action)}
textRight={getDisplayShortcut({ action })}
{...props}
>
{children}

View File

@ -78,7 +78,7 @@ export function TimelineToolbar({
function ToolbarLeftSection() {
const editor = useEditor();
const currentTime = editor.playback.getCurrentTime();
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime });
const handleAction = ({
action,
@ -116,7 +116,7 @@ function ToolbarLeftSection() {
<ToolbarButton
icon={<SplitSquareHorizontal />}
tooltip="Coming soon" /* separate audio */
tooltip="Separate audio (coming soon)"
disabled={true}
onClick={({ event: _event }) => {}}
/>
@ -131,7 +131,7 @@ function ToolbarLeftSection() {
<ToolbarButton
icon={<HugeiconsIcon icon={SnowIcon} />}
tooltip="Coming soon" /* freeze frame */
tooltip="Freeze frame (coming soon)"
disabled={true}
onClick={({ event: _event }) => {}}
/>
@ -149,8 +149,8 @@ function ToolbarLeftSection() {
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
isActive={currentBookmarked}
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
isActive={isCurrentlyBookmarked}
tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event })
}
@ -191,12 +191,10 @@ function ToolbarRightSection({
onZoomChange: (zoom: number) => void;
onZoom: (options: { direction: "in" | "out" }) => void;
}) {
const {
snappingEnabled,
rippleEditingEnabled,
toggleSnapping,
toggleRippleEditing,
} = useTimelineStore();
const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
return (
<div className="flex items-center gap-1">

View File

@ -52,7 +52,7 @@ export function TimelineTrackContent({
targetElementId = null,
}: TimelineTrackContentProps) {
const editor = useEditor();
const { isElementSelected, clearElementSelection } = useElementSelection();
const { isElementSelected } = useElementSelection();
const duration = editor.timeline.getTotalDuration();
@ -69,7 +69,6 @@ export function TimelineTrackContent({
className="size-full"
onClick={(event) => {
if (shouldIgnoreClick?.()) return;
clearElementSelection();
onTrackClick?.(event);
}}
onMouseDown={(event) => {

View File

@ -31,7 +31,7 @@ const contextMenuItemVariants = cva(
default:
"focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground",
destructive:
"text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
"text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive",
},
},
defaultVariants: {

View File

@ -17,7 +17,11 @@ export const FONT_SIZE_SCALE_REFERENCE = 90;
export const DEFAULT_LETTER_SPACING = 0;
export const DEFAULT_LINE_HEIGHT = 1.2;
export const CORNER_RADIUS_MIN = 0;
export const CORNER_RADIUS_MAX = 100;
export const DEFAULT_TEXT_BACKGROUND = {
enabled: false,
color: "#000000",
cornerRadius: 0,
paddingX: 30,

View File

@ -7,7 +7,7 @@ import type {
TProjectSettings,
TTimelineViewState,
} from "@/types/project";
import type { ExportOptions, ExportResult } from "@/types/export";
import type { ExportOptions, ExportResult, ExportState } from "@/types/export";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
@ -51,6 +51,12 @@ export class ProjectManager {
toVersion: null,
projectName: null,
};
private exportState: ExportState = {
isExporting: false,
progress: 0,
result: null,
};
private exportCancelRequested = false;
constructor(private editor: EditorCore) {}
@ -191,7 +197,40 @@ export class ProjectManager {
}
async export({ options }: { options: ExportOptions }): Promise<ExportResult> {
return this.editor.renderer.exportProject({ options });
this.exportCancelRequested = false;
this.exportState = { isExporting: true, progress: 0, result: null };
this.notify();
const result = await this.editor.renderer.exportProject({
options,
onProgress: ({ progress }) => {
this.exportState = { ...this.exportState, progress };
this.notify();
},
onCancel: () => this.exportCancelRequested,
});
this.exportState = {
isExporting: false,
progress: this.exportState.progress,
result,
};
this.notify();
return result;
}
cancelExport(): void {
this.exportCancelRequested = true;
}
clearExportState(): void {
this.exportState = { isExporting: false, progress: 0, result: null };
this.notify();
}
getExportState(): ExportState {
return this.exportState;
}
async loadAllProjects(): Promise<void> {

View File

@ -88,11 +88,14 @@ export class RendererManager {
async exportProject({
options,
onProgress,
onCancel,
}: {
options: ExportOptions;
onProgress?: ({ progress }: { progress: number }) => void;
onCancel?: () => boolean;
}): Promise<ExportResult> {
const { format, quality, fps, includeAudio, onProgress, onCancel } =
options;
const { format, quality, fps, includeAudio } = options;
try {
const tracks = this.editor.timeline.getTracks();

View File

@ -1,4 +1,5 @@
import type { EditorCore } from "@/core";
import type { EffectParamValues } from "@/types/effects";
import type {
TrackType,
TimelineTrack,
@ -32,6 +33,13 @@ import {
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
AddClipEffectCommand,
RemoveClipEffectCommand,
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
ReorderClipEffectsCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
} from "@/lib/commands/timeline";
import { BatchCommand, PreviewTracker } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
@ -62,17 +70,23 @@ export class TimelineManager {
elementId,
trimStart,
trimEnd,
startTime,
duration,
pushHistory = true,
}: {
elementId: string;
trimStart: number;
trimEnd: number;
startTime?: number;
duration?: number;
pushHistory?: boolean;
}): void {
const command = new UpdateElementTrimCommand({
elementId,
trimStart,
trimEnd,
startTime,
duration,
});
if (pushHistory) {
this.editor.command.execute({ command });
@ -124,12 +138,14 @@ export class TimelineManager {
elementId,
newStartTime,
createTrack,
rippleEnabled = false,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
rippleEnabled?: boolean;
}): void {
const command = new MoveElementCommand({
sourceTrackId,
@ -137,6 +153,7 @@ export class TimelineManager {
elementId,
newStartTime,
createTrack,
rippleEnabled,
});
this.editor.command.execute({ command });
}
@ -253,6 +270,104 @@ export class TimelineManager {
}
}
addClipEffect({
trackId,
elementId,
effectType,
}: {
trackId: string;
elementId: string;
effectType: string;
}): string {
const command = new AddClipEffectCommand({
trackId,
elementId,
effectType,
});
this.editor.command.execute({ command });
return command.getEffectId() ?? "";
}
removeClipEffect({
trackId,
elementId,
effectId,
}: {
trackId: string;
elementId: string;
effectId: string;
}): void {
const command = new RemoveClipEffectCommand({
trackId,
elementId,
effectId,
});
this.editor.command.execute({ command });
}
updateClipEffectParams({
trackId,
elementId,
effectId,
params,
pushHistory = true,
}: {
trackId: string;
elementId: string;
effectId: string;
params: Partial<EffectParamValues>;
pushHistory?: boolean;
}): void {
const command = new UpdateClipEffectParamsCommand({
trackId,
elementId,
effectId,
params,
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
toggleClipEffect({
trackId,
elementId,
effectId,
}: {
trackId: string;
elementId: string;
effectId: string;
}): void {
const command = new ToggleClipEffectCommand({
trackId,
elementId,
effectId,
});
this.editor.command.execute({ command });
}
reorderClipEffects({
trackId,
elementId,
fromIndex,
toIndex,
}: {
trackId: string;
elementId: string;
fromIndex: number;
toIndex: number;
}): void {
const command = new ReorderClipEffectsCommand({
trackId,
elementId,
fromIndex,
toIndex,
});
this.editor.command.execute({ command });
}
upsertKeyframes({
keyframes,
}: {
@ -346,6 +461,61 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
upsertEffectParamKeyframe({
trackId,
elementId,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
trackId: string;
elementId: string;
effectId: string;
paramKey: string;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
}): void {
const command = new UpsertEffectParamKeyframeCommand({
trackId,
elementId,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
});
this.editor.command.execute({ command });
}
removeEffectParamKeyframe({
trackId,
elementId,
effectId,
paramKey,
keyframeId,
}: {
trackId: string;
elementId: string;
effectId: string;
paramKey: string;
keyframeId: string;
}): void {
const command = new RemoveEffectParamKeyframeCommand({
trackId,
elementId,
effectId,
paramKey,
keyframeId,
});
this.editor.command.execute({ command });
}
isPreviewActive(): boolean {
return this.previewTracker.isActive();
}

View File

@ -4,19 +4,19 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import { getElementsAtTime } from "@/lib/timeline";
export function useEditorActions() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { selectedElements, setElementSelection } = useElementSelection();
const {
clipboard,
setClipboard,
toggleSnapping,
rippleEditingEnabled,
toggleRippleEditing,
} = useTimelineStore();
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
const clipboard = useTimelineStore((s) => s.clipboard);
const setClipboard = useTimelineStore((s) => s.setClipboard);
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
useActionHandler(
"toggle-play",
@ -209,6 +209,11 @@ export function useEditorActions() {
useActionHandler(
"delete-selected",
() => {
if (selectedKeyframes.length > 0) {
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
clearKeyframeSelection();
return;
}
if (selectedElements.length === 0) {
return;
}
@ -235,6 +240,19 @@ export function useEditorActions() {
undefined,
);
useActionHandler(
"deselect-all",
() => {
setElementSelection({ elements: [] });
clearKeyframeSelection();
const activeElement = document.activeElement;
if (activeElement instanceof HTMLButtonElement) {
activeElement.blur();
}
},
undefined,
);
useActionHandler(
"duplicate-selected",
() => {
@ -278,7 +296,7 @@ export function useEditorActions() {
elements: selectedElements,
});
const items = results.map(({ track, element }) => {
const { ...elementWithoutId } = element;
const { id: _, ...elementWithoutId } = element;
return {
trackId: track.id,
trackType: track.type,

View File

@ -8,6 +8,7 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useTimelineStore } from "@/stores/timeline-store";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import {
DRAG_THRESHOLD_PX,
@ -38,6 +39,8 @@ interface UseElementInteractionProps {
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
const MOUSE_BUTTON_RIGHT = 2;
const initialDragState: ElementDragState = {
isDragging: false,
elementId: null,
@ -162,6 +165,7 @@ export function useElementInteraction({
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const {
@ -441,27 +445,33 @@ export function useElementInteraction({
return;
}
if (dropTarget.isNewTrack) {
const newTrackId = generateUUID();
if (dropTarget.isNewTrack) {
const newTrackId = generateUUID();
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: newTrackId,
elementId: dragState.elementId,
newStartTime: snappedTime,
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
rippleEnabled: rippleEditingEnabled,
});
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
} else {
const targetTrack = tracks[dropTarget.trackIndex];
if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: newTrackId,
targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
rippleEnabled: rippleEditingEnabled,
});
} else {
const targetTrack = tracks[dropTarget.trackIndex];
if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
});
if (targetTrack.id !== dragState.trackId) {
selectElement({ trackId: targetTrack.id, elementId: dragState.elementId });
}
}
}
endDrag();
onSnapPointChange?.(null);
@ -483,6 +493,8 @@ export function useElementInteraction({
tracksContainerRef,
tracksScrollRef,
headerRef,
rippleEditingEnabled,
selectElement,
]);
useEffect(() => {
@ -508,10 +520,10 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
const isRightClick = event.button === 2;
const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
// right-click: don't stop propagation so ContextMenu can open
if (isRightClick) {
// right-click: don't stop propagation so ContextMenu can open
if (isRightClick) {
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
@ -526,14 +538,12 @@ export function useElementInteraction({
return;
}
// left-click: stop propagation for drag operations
event.stopPropagation();
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
event.stopPropagation();
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
// multi-select: toggle selection
if (isMultiSelect) {
if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
@ -541,8 +551,7 @@ export function useElementInteraction({
});
}
// start drag
const clickOffsetTime = getClickOffsetTime({
const clickOffsetTime = getClickOffsetTime({
clientX: event.clientX,
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
@ -570,10 +579,9 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
event.stopPropagation();
event.stopPropagation();
// was it a drag or a click?
if (mouseDownLocationRef.current) {
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
@ -585,8 +593,7 @@ export function useElementInteraction({
// modifier keys already handled in mousedown
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
// single click: select if not selected
const alreadySelected = isElementSelected({
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});

View File

@ -137,18 +137,52 @@ export function useTimelineElementResize({
}
onSnapPointChange?.(resizeSnapPoint);
const otherElements = track.elements.filter(({ id }) => id !== element.id);
const initialEndTime = resizing.initialStartTime + resizing.initialDuration;
const rightNeighborBound =
resizing.side === "right"
? otherElements
.filter(({ startTime }) => startTime >= initialEndTime)
.reduce((min, { startTime }) => Math.min(min, startTime), Infinity)
: Infinity;
const leftNeighborBound =
resizing.side === "left"
? otherElements
.filter(
({ startTime, duration }) =>
startTime + duration <= resizing.initialStartTime,
)
.reduce(
(max, { startTime, duration }) =>
Math.max(max, startTime + duration),
-Infinity,
)
: -Infinity;
if (resizing.side === "left") {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
? Math.max(
0,
resizing.initialTrimStart +
(leftNeighborBound - resizing.initialStartTime),
)
: 0;
const maxAllowed =
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
time: Math.min(
maxAllowed,
Math.max(minTrimStartForNeighbor, calculated),
),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
@ -171,7 +205,16 @@ export function useTimelineElementResize({
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
const maxExtension = resizing.initialStartTime;
const actualExtension = Math.min(extensionAmount, maxExtension);
const actualExtension = Math.max(
0,
Number.isFinite(leftNeighborBound)
? Math.min(
extensionAmount,
maxExtension,
resizing.initialStartTime - leftNeighborBound,
)
: Math.min(extensionAmount, maxExtension),
);
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
@ -188,7 +231,8 @@ export function useTimelineElementResize({
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
const trimDelta = 0 - resizing.initialTrimStart;
const trimDelta =
minTrimStartForNeighbor - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
@ -198,10 +242,10 @@ export function useTimelineElementResize({
fps: projectFps,
});
setCurrentTrimStart(0);
setCurrentTrimStart(minTrimStartForNeighbor);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentTrimStartRef.current = minTrimStartForNeighbor;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
@ -212,6 +256,9 @@ export function useTimelineElementResize({
resizing.initialDuration +
resizing.initialTrimEnd;
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
const maxAllowedDuration = Number.isFinite(rightNeighborBound)
? rightNeighborBound - resizing.initialStartTime
: Infinity;
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
@ -219,7 +266,7 @@ export function useTimelineElementResize({
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: baseDuration + extensionNeeded,
time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration),
fps: projectFps,
});
@ -230,7 +277,10 @@ export function useTimelineElementResize({
} else {
const extensionToLimit = resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionToLimit,
time: Math.min(
resizing.initialDuration + extensionToLimit,
maxAllowedDuration,
),
fps: projectFps,
});
@ -240,9 +290,20 @@ export function useTimelineElementResize({
currentTrimEndRef.current = 0;
}
} else {
const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
? Math.max(
0,
resizing.initialDuration +
resizing.initialTrimEnd -
maxAllowedDuration,
)
: 0;
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
@ -267,6 +328,7 @@ export function useTimelineElementResize({
snappingEnabled,
editor,
element.id,
track.elements,
onSnapPointChange,
canExtendElementDuration,
isShiftHeldRef,
@ -285,26 +347,13 @@ export function useTimelineElementResize({
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
const durationChanged = finalDuration !== resizing.initialDuration;
if (trimStartChanged || trimEndChanged) {
if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
trimEnd: finalTrimEnd,
});
}
if (startTimeChanged) {
editor.timeline.updateElementStartTime({
elements: [{ trackId: track.id, elementId: element.id }],
startTime: finalStartTime,
});
}
if (durationChanged) {
editor.timeline.updateElementDuration({
trackId: track.id,
elementId: element.id,
duration: finalDuration,
startTime: startTimeChanged ? finalStartTime : undefined,
duration: durationChanged ? finalDuration : undefined,
});
}
@ -315,7 +364,6 @@ export function useTimelineElementResize({
resizing,
editor.timeline,
element.id,
track.id,
onResizeStateChange,
onSnapPointChange,
]);

View File

@ -0,0 +1,281 @@
import {
useState,
useCallback,
useEffect,
useRef,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame } from "@/lib/time";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { DRAG_THRESHOLD_PX, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands";
import type { SelectedKeyframeRef } from "@/types/animation";
import type { TimelineElement } from "@/types/timeline";
import type { Command } from "@/lib/commands/base-command";
export interface KeyframeDragState {
isDragging: boolean;
draggingKeyframeIds: Set<string>;
deltaTime: number;
}
const initialDragState: KeyframeDragState = {
isDragging: false,
draggingKeyframeIds: new Set(),
deltaTime: 0,
};
interface PendingKeyframeDrag {
keyframeRefs: SelectedKeyframeRef[];
startMouseX: number;
}
export function useKeyframeDrag({
zoomLevel,
element,
}: {
zoomLevel: number;
element: TimelineElement;
}) {
const editor = useEditor();
const {
selectedKeyframes,
isKeyframeSelected,
toggleKeyframeSelection,
selectKeyframeRange,
} = useKeyframeSelection();
const [dragState, setDragState] =
useState<KeyframeDragState>(initialDragState);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
const mouseDownXRef = useRef<number | null>(null);
const activeProject = editor.project.getActive();
const fps = activeProject.settings.fps;
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const endDrag = useCallback(() => {
setDragState(initialDragState);
}, []);
const commitDrag = useCallback(
({
keyframeRefs,
deltaTime,
}: {
keyframeRefs: SelectedKeyframeRef[];
deltaTime: number;
}) => {
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
const channel = element.animations?.channels[keyframeRef.propertyPath];
const keyframe = channel?.keyframes.find(
(keyframe) => keyframe.id === keyframeRef.keyframeId,
);
if (!keyframe) return [];
const nextTime = Math.max(
0,
Math.min(element.duration, keyframe.time + deltaTime),
);
return [
new RetimeKeyframeCommand({
trackId: keyframeRef.trackId,
elementId: keyframeRef.elementId,
propertyPath: keyframeRef.propertyPath,
keyframeId: keyframeRef.keyframeId,
nextTime,
}),
];
});
if (commands.length === 1) {
editor.command.execute({ command: commands[0] });
} else if (commands.length > 1) {
editor.command.execute({ command: new BatchCommand(commands) });
}
},
[editor.command, element],
);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
const handleMouseMove = ({ clientX }: MouseEvent) => {
if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
if (deltaX <= DRAG_THRESHOLD_PX) return;
const pending = pendingDragRef.current;
pendingDragRef.current = null;
setIsPendingDrag(false);
setDragState({
isDragging: true,
draggingKeyframeIds: new Set(
pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
),
deltaTime: 0,
});
return;
}
if (!dragState.isDragging) return;
const startX = mouseDownXRef.current ?? clientX;
const rawDelta = (clientX - startX) / pixelsPerSecond;
const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
};
document.addEventListener("mousemove", handleMouseMove);
return () => document.removeEventListener("mousemove", handleMouseMove);
}, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]);
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseUp = () => {
const draggingRefs = selectedKeyframes.filter(
(keyframe) =>
keyframe.elementId === element.id &&
dragState.draggingKeyframeIds.has(keyframe.keyframeId),
);
if (draggingRefs.length > 0 && dragState.deltaTime !== 0) {
commitDrag({
keyframeRefs: draggingRefs,
deltaTime: dragState.deltaTime,
});
}
endDrag();
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [
dragState.isDragging,
dragState.draggingKeyframeIds,
dragState.deltaTime,
selectedKeyframes,
element.id,
commitDrag,
endDrag,
]);
useEffect(() => {
if (!isPendingDrag) return;
const handleMouseUp = () => {
pendingDragRef.current = null;
setIsPendingDrag(false);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [isPendingDrag]);
const handleKeyframeMouseDown = useCallback(
({
event,
keyframes,
}: {
event: ReactMouseEvent;
keyframes: SelectedKeyframeRef[];
}) => {
event.preventDefault();
event.stopPropagation();
mouseDownXRef.current = event.clientX;
const anySelected = keyframes.some((keyframe) =>
isKeyframeSelected({ keyframe }),
);
const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
pendingDragRef.current = {
keyframeRefs: keyframeRefsToTrack,
startMouseX: event.clientX,
};
setIsPendingDrag(true);
},
[isKeyframeSelected, selectedKeyframes],
);
const handleKeyframeClick = useCallback(
({
event,
keyframes,
orderedKeyframes,
}: {
event: ReactMouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
}) => {
event.stopPropagation();
const wasDrag =
mouseDownXRef.current !== null &&
Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX;
mouseDownXRef.current = null;
if (wasDrag) return;
if (event.shiftKey) {
selectKeyframeRange({
orderedKeyframes,
targetKeyframes: keyframes,
isAdditive: event.metaKey || event.ctrlKey,
});
return;
}
toggleKeyframeSelection({
keyframes,
isMultiKey: event.metaKey || event.ctrlKey,
});
},
[toggleKeyframeSelection, selectKeyframeRange],
);
const getVisualOffsetPx = useCallback(
({
indicatorTime,
indicatorOffsetPx,
isBeingDragged,
displayedStartTime,
elementLeft,
}: {
indicatorTime: number;
indicatorOffsetPx: number;
isBeingDragged: boolean;
displayedStartTime: number;
elementLeft: number;
}): number => {
if (!isBeingDragged) return indicatorOffsetPx;
const clampedTime = Math.max(
0,
Math.min(element.duration, indicatorTime + dragState.deltaTime),
);
return (
timelineTimeToSnappedPixels({
time: displayedStartTime + clampedTime,
zoomLevel,
}) - elementLeft
);
},
[dragState.deltaTime, element.duration, zoomLevel],
);
return {
keyframeDragState: dragState,
handleKeyframeMouseDown,
handleKeyframeClick,
getVisualOffsetPx,
};
}

View File

@ -26,12 +26,14 @@ import type {
interface UseTimelineDragDropProps {
containerRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
tracksScrollRef?: RefObject<HTMLDivElement | null>;
zoomLevel: number;
}
export function useTimelineDragDrop({
containerRef,
headerRef,
tracksScrollRef,
zoomLevel,
}: UseTimelineDragDropProps) {
const editor = useEditor();
@ -104,11 +106,16 @@ export function useTimelineDragDrop({
(e: React.DragEvent) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const scrollContainer = tracksScrollRef?.current;
const referenceRect =
scrollContainer?.getBoundingClientRect() ??
containerRef.current?.getBoundingClientRect();
if (!referenceRect) return;
const headerHeight =
headerRef?.current?.getBoundingClientRect().height ?? 0;
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
const scrollTop = scrollContainer?.scrollTop ?? 0;
const hasFiles = e.dataTransfer.types.includes("Files");
const isExternal =
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
@ -131,8 +138,8 @@ export function useTimelineDragDrop({
mediaId: dragData?.type === "media" ? dragData.id : undefined,
});
const mouseX = e.clientX - rect.left;
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
const mouseX = e.clientX - referenceRect.left + scrollLeft;
const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
const targetElementTypes =
dragData?.type === "effect"
@ -162,6 +169,7 @@ export function useTimelineDragDrop({
[
containerRef,
headerRef,
tracksScrollRef,
tracks,
currentTime,
zoomLevel,
@ -200,19 +208,6 @@ export function useTimelineDragDrop({
target: DropTarget;
dragData: { name?: string; content?: string };
}) => {
let trackId: string;
if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: "text",
index: target.trackIndex,
});
} else {
const track = tracks[target.trackIndex];
if (!track) return;
trackId = track.id;
}
const element = buildTextElement({
raw: {
name: dragData.name ?? "",
@ -221,12 +216,26 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
if (target.isNewTrack) {
const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
});
editor.command.execute({
command: new BatchCommand([addTrackCmd, insertCmd]),
});
return;
}
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
placement: { mode: "explicit", trackId: track.id },
element,
});
},
[editor.timeline, tracks],
[editor.command, editor.timeline, tracks],
);
const executeStickerDrop = useCallback(
@ -237,31 +246,32 @@ export function useTimelineDragDrop({
target: DropTarget;
dragData: StickerDragData;
}) => {
let trackId: string;
if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: "sticker",
index: target.trackIndex,
});
} else {
const track = tracks[target.trackIndex];
if (!track) return;
trackId = track.id;
}
const element = buildStickerElement({
stickerId: dragData.stickerId,
name: dragData.name,
startTime: target.xPosition,
});
if (target.isNewTrack) {
const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex);
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
});
editor.command.execute({
command: new BatchCommand([addTrackCmd, insertCmd]),
});
return;
}
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
placement: { mode: "explicit", trackId: track.id },
element,
});
},
[editor.timeline, tracks],
[editor.command, editor.timeline, tracks],
);
const executeMediaDrop = useCallback(
@ -276,18 +286,6 @@ export function useTimelineDragDrop({
const trackType: TrackType =
dragData.mediaType === "audio" ? "audio" : "video";
let trackId: string;
if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: trackType,
index: target.trackIndex,
});
} else {
const track = tracks[target.trackIndex];
if (!track) return;
trackId = track.id;
}
const duration =
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
@ -299,31 +297,63 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
if (target.isNewTrack) {
const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
});
editor.command.execute({
command: new BatchCommand([addTrackCmd, insertCmd]),
});
return;
}
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
placement: { mode: "explicit", trackId: track.id },
element,
});
},
[editor.timeline, mediaAssets, tracks],
[editor.command, editor.timeline, mediaAssets, tracks],
);
const executeEffectDrop = useCallback(
({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => {
({
target,
dragData,
}: {
target: DropTarget;
dragData: EffectDragData;
}) => {
if (target.targetElement) {
editor.timeline.addClipEffect({
trackId: target.targetElement.trackId,
elementId: target.targetElement.elementId,
effectType: dragData.effectType,
});
return;
}
const effectTrack = tracks.find((t) => t.type === "effect");
let trackId: string;
if (effectTrack && !target.targetElement) {
if (effectTrack) {
trackId = effectTrack.id;
} else if (target.targetElement) {
trackId = effectTrack?.id ?? editor.timeline.addTrack({
type: "effect",
index: 0,
});
} else if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: "effect",
index: target.trackIndex,
const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
const insertCmd = new InsertElementCommand({
element: buildEffectElement({
effectType: dragData.effectType,
startTime: target.xPosition,
}),
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
});
editor.command.execute({
command: new BatchCommand([addTrackCmd, insertCmd]),
});
return;
} else {
const track = tracks[target.trackIndex];
if (!track || track.type !== "effect") return;
@ -340,7 +370,7 @@ export function useTimelineDragDrop({
element,
});
},
[editor.timeline, tracks],
[editor.command, editor.timeline, tracks],
);
const executeFileDrop = useCallback(
@ -452,12 +482,18 @@ export function useTimelineDragDrop({
executeMediaDrop({ target: currentTarget, dragData });
}
} else if (hasFiles) {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = e.clientX - rect.left;
const scrollContainer = tracksScrollRef?.current;
const referenceRect =
scrollContainer?.getBoundingClientRect() ??
containerRef.current?.getBoundingClientRect();
if (!referenceRect) return;
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
const scrollTop = scrollContainer?.scrollTop ?? 0;
const mouseX = e.clientX - referenceRect.left + scrollLeft;
const headerHeight =
headerRef?.current?.getBoundingClientRect().height ?? 0;
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
const mouseY =
e.clientY - referenceRect.top + scrollTop - headerHeight;
await executeFileDrop({
files: Array.from(e.dataTransfer.files),
mouseX,
@ -478,6 +514,7 @@ export function useTimelineDragDrop({
executeFileDrop,
containerRef,
headerRef,
tracksScrollRef,
],
);

View File

@ -1,7 +1,6 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useSyncExternalStore } from "react";
import {
getVisibleElementsWithBounds,
type ElementWithBounds,
@ -18,7 +17,13 @@ import {
type SnapLine,
} from "@/lib/preview/preview-snap";
import { isVisualElement } from "@/lib/timeline/element-utils";
import {
getElementLocalTime,
resolveTransformAtTime,
setChannel,
} from "@/lib/animation";
import type { Transform } from "@/types/timeline";
import type { ElementAnimations } from "@/types/animation";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type HandleType = Corner | "rotation";
@ -32,6 +37,8 @@ interface ScaleState {
initialBoundsCy: number;
baseWidth: number;
baseHeight: number;
shouldClearScaleAnimation: boolean;
animationsWithoutScale: ElementAnimations | undefined;
}
interface RotationState {
@ -78,16 +85,16 @@ function getCornerDistance({
};
corner: Corner;
}): number {
const halfW = bounds.width / 2;
const halfH = bounds.height / 2;
const halfWidth = bounds.width / 2;
const halfHeight = 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;
corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
const localY =
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
const rotatedX = localX * cos - localY * sin;
const rotatedY = localX * sin + localY * cos;
@ -114,6 +121,8 @@ export function useTransformHandles({
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
@ -144,19 +153,41 @@ export function useTransformHandles({
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
const localTime = getElementLocalTime({
timelineTime: currentTimeRef.current,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const initialDistance = getCornerDistance({ bounds, corner });
const baseWidth = bounds.width / element.transform.scale;
const baseHeight = bounds.height / element.transform.scale;
const baseWidth = bounds.width / resolvedTransform.scale;
const baseHeight = bounds.height / resolvedTransform.scale;
const shouldClearScaleAnimation =
!!element.animations?.channels["transform.scale"];
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
animations: element.animations,
propertyPath: "transform.scale",
channel: undefined,
})
: element.animations;
scaleStateRef.current = {
trackId,
elementId,
initialTransform: element.transform,
initialTransform: resolvedTransform,
initialDistance,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
baseWidth,
baseHeight,
shouldClearScaleAnimation,
animationsWithoutScale,
};
setActiveHandle(corner);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
@ -172,19 +203,30 @@ export function useTransformHandles({
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
const localTime = getElementLocalTime({
timelineTime: currentTimeRef.current,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const position = screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
canvas: canvasRef.current,
});
const dx = position.x - bounds.cx;
const dy = position.y - bounds.cy;
const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
const deltaX = position.x - bounds.cx;
const deltaY = position.y - bounds.cy;
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
rotationStateRef.current = {
trackId,
elementId,
initialTransform: element.transform,
initialTransform: resolvedTransform,
initialAngle,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
@ -220,11 +262,13 @@ export function useTransformHandles({
initialBoundsCy,
baseWidth,
baseHeight,
shouldClearScaleAnimation,
animationsWithoutScale,
} = scaleStateRef.current;
const dx = position.x - initialBoundsCx;
const dy = position.y - initialBoundsCy;
const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1;
const deltaX = position.x - initialBoundsCx;
const deltaY = position.y - initialBoundsCy;
const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
const scaleFactor = currentDistance / initialDistance;
const proposedScale = Math.max(
MIN_SCALE,
@ -258,14 +302,22 @@ export function useTransformHandles({
setSnapLines(activeLines);
}
const updates: {
transform: Transform;
animations?: ElementAnimations;
} = {
transform: { ...initialTransform, scale: snappedScale },
};
if (shouldClearScaleAnimation) {
updates.animations = animationsWithoutScale;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
transform: { ...initialTransform, scale: snappedScale },
},
updates,
},
],
});
@ -282,9 +334,9 @@ export function useTransformHandles({
initialBoundsCy,
} = rotationStateRef.current;
const dx = position.x - initialBoundsCx;
const dy = position.y - initialBoundsCy;
const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
const deltaX = position.x - initialBoundsCx;
const deltaY = position.y - initialBoundsCy;
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
let deltaAngle = currentAngle - initialAngle;
if (deltaAngle > 180) deltaAngle -= 360;
if (deltaAngle < -180) deltaAngle += 360;

View File

@ -114,6 +114,11 @@ export const ACTIONS = {
category: "selection",
defaultShortcuts: ["ctrl+a"],
},
"deselect-all": {
description: "Deselect all elements",
category: "selection",
defaultShortcuts: ["escape"],
},
"duplicate-selected": {
description: "Duplicate selected element",
category: "selection",
@ -145,7 +150,7 @@ export const ACTIONS = {
export type TAction = keyof typeof ACTIONS;
export function getActionDefinition(action: TAction): TActionDefinition {
export function getActionDefinition({ action }: { action: TAction }): TActionDefinition {
return ACTIONS[action];
}

View File

@ -0,0 +1,117 @@
import type { Effect, EffectParamValues } from "@/types/effects";
import type {
ElementAnimations,
NumberAnimationChannel,
} from "@/types/animation";
import {
getChannel,
removeKeyframe,
setChannel,
upsertKeyframe,
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
const EFFECT_PARAM_PATH_PREFIX = "effects.";
const EFFECT_PARAM_PATH_SUFFIX = ".params.";
function buildEffectParamPath({
effectId,
paramKey,
}: {
effectId: string;
paramKey: string;
}): string {
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
}
export function resolveEffectParamsAtTime({
effect,
animations,
localTime,
}: {
effect: Effect;
animations: ElementAnimations | undefined;
localTime: number;
}): EffectParamValues {
const resolved: EffectParamValues = {};
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
const channel = getChannel({ animations, propertyPath: path });
if (channel && channel.keyframes.length > 0) {
resolved[paramKey] = getChannelValueAtTime({
channel,
time: localTime,
fallbackValue: staticValue,
}) as number | string | boolean;
} else {
resolved[paramKey] = staticValue;
}
}
return resolved;
}
const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
valueKind: "number",
keyframes: [],
};
export function upsertEffectParamKeyframe({
animations,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
effectId: string;
paramKey: string;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const targetChannel =
channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
const updatedChannel = upsertKeyframe({
channel: targetChannel,
time,
value,
interpolation: interpolation ?? "linear",
keyframeId,
});
return (
setChannel({
animations,
propertyPath: path,
channel: updatedChannel,
}) ?? { channels: {} }
);
}
export function removeEffectParamKeyframe({
animations,
effectId,
paramKey,
keyframeId,
}: {
animations: ElementAnimations | undefined;
effectId: string;
paramKey: string;
keyframeId: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const updatedChannel = removeKeyframe({ channel, keyframeId });
return setChannel({
animations,
propertyPath: path,
channel: updatedChannel,
});
}

View File

@ -127,7 +127,9 @@ function buildKeyframe({
}
if (typeof value !== "string" && typeof value !== "boolean") {
throw new Error("Discrete channel keyframes require boolean or string values");
throw new Error(
"Discrete channel keyframes require boolean or string values",
);
}
return {
@ -145,14 +147,23 @@ function createEmptyChannel({
}): AnimationChannel {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
if (propertyDefinition.valueKind === "number") {
return { valueKind: "number", keyframes: [] } satisfies NumberAnimationChannel;
return {
valueKind: "number",
keyframes: [],
} satisfies NumberAnimationChannel;
}
if (propertyDefinition.valueKind === "color") {
return { valueKind: "color", keyframes: [] } satisfies ColorAnimationChannel;
return {
valueKind: "color",
keyframes: [],
} satisfies ColorAnimationChannel;
}
return { valueKind: "discrete", keyframes: [] } satisfies DiscreteAnimationChannel;
return {
valueKind: "discrete",
keyframes: [],
} satisfies DiscreteAnimationChannel;
}
export function upsertKeyframe({
@ -432,7 +443,10 @@ export function splitAnimationsAtTime({
const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
);
if (shouldIncludeSplitBoundary && (!hasBoundaryOnLeft || !hasBoundaryOnRight)) {
if (
shouldIncludeSplitBoundary &&
(!hasBoundaryOnLeft || !hasBoundaryOnRight)
) {
const boundaryValue = getChannelValueAtTime({
channel: normalizedChannel,
time: splitTime,
@ -442,7 +456,9 @@ export function splitAnimationsAtTime({
? (propertyPath as AnimationPropertyPath)
: null;
const boundaryInterpolation = knownPropertyPath
? getDefaultInterpolationForProperty({ propertyPath: knownPropertyPath })
? getDefaultInterpolationForProperty({
propertyPath: knownPropertyPath,
})
: normalizedChannel.valueKind === "discrete"
? "hold"
: "linear";
@ -523,7 +539,9 @@ export function upsertElementKeyframe({
return animations;
}
const defaultInterpolation = getDefaultInterpolationForProperty({ propertyPath });
const defaultInterpolation = getDefaultInterpolationForProperty({
propertyPath,
});
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
const channel = getChannel({ animations, propertyPath });
const targetChannel =

View File

@ -0,0 +1,74 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
import { buildDefaultEffectInstance } from "@/lib/effects";
function addEffectToElement({
element,
effectType,
}: {
element: VisualElement;
effectType: string;
}): VisualElement {
const instance = buildDefaultEffectInstance({ effectType });
const currentEffects = element.effects ?? [];
return { ...element, effects: [...currentEffects, instance] };
}
export class AddClipEffectCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private effectId: string | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectType: string;
constructor({
trackId,
elementId,
effectType,
}: {
trackId: string;
elementId: string;
effectType: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectType = effectType;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
const updated = addEffectToElement({
element: element as VisualElement,
effectType: this.effectType,
});
const effects = updated.effects ?? [];
this.effectId = effects[effects.length - 1]?.id ?? null;
return updated;
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getEffectId(): string | null {
return this.effectId;
}
}

View File

@ -0,0 +1,5 @@
export { AddClipEffectCommand } from "./add-effect";
export { RemoveClipEffectCommand } from "./remove-effect";
export { ToggleClipEffectCommand } from "./toggle-effect";
export { UpdateClipEffectParamsCommand } from "./update-effect-params";
export { ReorderClipEffectsCommand } from "./reorder-effect";

View File

@ -0,0 +1,65 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
function removeEffectFromElement({
element,
effectId,
}: {
element: VisualElement;
effectId: string;
}): VisualElement {
const currentEffects = element.effects ?? [];
const filtered = currentEffects.filter((effect) => effect.id !== effectId);
return { ...element, effects: filtered };
}
export class RemoveClipEffectCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
constructor({
trackId,
elementId,
effectId,
}: {
trackId: string;
elementId: string;
effectId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
return removeEffectFromElement({
element: element as VisualElement,
effectId: this.effectId,
});
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,73 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
function reorderEffectsOnElement({
element,
fromIndex,
toIndex,
}: {
element: VisualElement;
fromIndex: number;
toIndex: number;
}): VisualElement {
const effects = [...(element.effects ?? [])];
const [moved] = effects.splice(fromIndex, 1);
effects.splice(toIndex, 0, moved);
return { ...element, effects };
}
export class ReorderClipEffectsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly fromIndex: number;
private readonly toIndex: number;
constructor({
trackId,
elementId,
fromIndex,
toIndex,
}: {
trackId: string;
elementId: string;
fromIndex: number;
toIndex: number;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.fromIndex = fromIndex;
this.toIndex = toIndex;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
return reorderEffectsOnElement({
element: element as VisualElement,
fromIndex: this.fromIndex,
toIndex: this.toIndex,
});
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,67 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
export function toggleEffectOnElement({
element,
effectId,
}: {
element: VisualElement;
effectId: string;
}): VisualElement {
const currentEffects = element.effects ?? [];
const updated = currentEffects.map((effect) =>
effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
);
return { ...element, effects: updated };
}
export class ToggleClipEffectCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
constructor({
trackId,
elementId,
effectId,
}: {
trackId: string;
elementId: string;
effectId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
return toggleEffectOnElement({
element: element as VisualElement,
effectId: this.effectId,
});
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -0,0 +1,86 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { EffectParamValues } from "@/types/effects";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
function updateEffectParamsOnElement({
element,
effectId,
params,
}: {
element: VisualElement;
effectId: string;
params: Partial<EffectParamValues>;
}): VisualElement {
const currentEffects = element.effects ?? [];
const updated = currentEffects.map((effect) => {
if (effect.id !== effectId) {
return effect;
}
const nextParams = { ...effect.params };
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
nextParams[key] = value;
}
}
return { ...effect, params: nextParams };
});
return { ...element, effects: updated };
}
export class UpdateClipEffectParamsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
private readonly params: Partial<EffectParamValues>;
constructor({
trackId,
elementId,
effectId,
params,
}: {
trackId: string;
elementId: string;
effectId: string;
params: Partial<EffectParamValues>;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
this.params = params;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
return updateEffectParamsOnElement({
element: element as VisualElement,
effectId: this.effectId,
params: this.params,
});
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -10,3 +10,4 @@ export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
export * from "./effects";

View File

@ -1,3 +1,5 @@
export * from "./remove-effect-param-keyframe";
export * from "./remove-keyframe";
export * from "./retime-keyframe";
export * from "./upsert-effect-param-keyframe";
export * from "./upsert-keyframe";

View File

@ -0,0 +1,68 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/types/timeline";
export class RemoveEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
private readonly paramKey: string;
private readonly keyframeId: string;
constructor({
trackId,
elementId,
effectId,
paramKey,
keyframeId,
}: {
trackId: string;
elementId: string;
effectId: string;
paramKey: string;
keyframeId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
this.paramKey = paramKey;
this.keyframeId = keyframeId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
const animations = removeEffectParamKeyframe({
animations: element.animations,
effectId: this.effectId,
paramKey: this.paramKey,
keyframeId: this.keyframeId,
});
return { ...element, animations };
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}

View File

@ -0,0 +1,84 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/types/timeline";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
private readonly paramKey: string;
private readonly time: number;
private readonly value: number;
private readonly interpolation: "linear" | "hold" | undefined;
private readonly keyframeId: string | undefined;
constructor({
trackId,
elementId,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
trackId: string;
elementId: string;
effectId: string;
paramKey: string;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
this.paramKey = paramKey;
this.time = time;
this.value = value;
this.interpolation = interpolation;
this.keyframeId = keyframeId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
const animations = upsertEffectParamKeyframe({
animations: element.animations,
effectId: this.effectId,
paramKey: this.paramKey,
time: boundedTime,
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
});
return { ...element, animations };
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}

View File

@ -11,6 +11,7 @@ import {
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@ -19,6 +20,7 @@ export class MoveElementCommand extends Command {
private readonly elementId: string;
private readonly newStartTime: number;
private readonly createTrack: { type: TrackType; index: number } | undefined;
private readonly rippleEnabled: boolean;
constructor({
sourceTrackId,
@ -26,12 +28,14 @@ export class MoveElementCommand extends Command {
elementId,
newStartTime,
createTrack,
rippleEnabled = false,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
rippleEnabled?: boolean;
}) {
super();
this.sourceTrackId = sourceTrackId;
@ -39,6 +43,7 @@ export class MoveElementCommand extends Command {
this.elementId = elementId;
this.newStartTime = newStartTime;
this.createTrack = createTrack;
this.rippleEnabled = rippleEnabled;
}
execute(): void {
@ -53,8 +58,7 @@ export class MoveElementCommand extends Command {
);
if (!sourceTrack || !element) {
console.error("Source track or element not found");
return;
throw new Error("Source track or element not found");
}
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
@ -69,8 +73,7 @@ export class MoveElementCommand extends Command {
targetTrack = newTrack;
}
if (!targetTrack) {
console.error("Target track not found");
return;
throw new Error("Target track not found");
}
const validation = validateElementTrackCompatibility({
@ -79,8 +82,7 @@ export class MoveElementCommand extends Command {
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
throw new Error(validation.errorMessage);
}
const adjustedStartTime = enforceMainTrackStart({
@ -105,27 +107,32 @@ export class MoveElementCommand extends Command {
elements: track.elements.map((trackElement) =>
trackElement.id === this.elementId ? movedElement : trackElement,
),
};
} as typeof track;
}
if (track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.filter(
(trackElement) => trackElement.id !== this.elementId,
),
};
const remainingElements = track.elements.filter(
(trackElement) => trackElement.id !== this.elementId,
);
const shiftedElements = this.rippleEnabled
? rippleShiftElements({
elements: remainingElements,
afterTime: element.startTime,
shiftAmount: element.duration,
})
: remainingElements;
return { ...track, elements: shiftedElements } as typeof track;
}
if (track.id === this.targetTrackId) {
return {
...track,
elements: [...track.elements, movedElement],
};
} as typeof track;
}
return track;
});
return track;
});
if (!isSameTrack) {
const sourceTrackAfterMove = updatedTracks.find(

View File

@ -16,3 +16,23 @@ export function getExportFileExtension({
}): string {
return `.${format}`;
}
export function downloadBuffer({
buffer,
filename,
mimeType,
}: {
buffer: ArrayBuffer;
filename: string;
mimeType: string;
}): void {
const blob = new Blob([buffer], { type: mimeType });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(url);
}

View File

@ -1,5 +1,5 @@
import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
import type { TextElement } from "@/types/timeline";
import type { TextBackground, TextElement } from "@/types/timeline";
type TextRect = {
left: number;
@ -74,8 +74,12 @@ function getTextRect({
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
}): TextRect {
const left =
textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2;
const textAlignToLeft: Record<typeof textAlign, number> = {
left: 0,
right: -block.maxWidth,
center: -block.maxWidth / 2,
};
const left = textAlignToLeft[textAlign];
return {
left,
@ -88,9 +92,13 @@ function getTextRect({
function isTextBackgroundVisible({
background,
}: {
background: TextElement["background"];
background: TextBackground;
}): boolean {
return Boolean(background.color) && background.color !== "transparent";
return (
background.enabled &&
Boolean(background.color) &&
background.color !== "transparent"
);
}
export function getTextBackgroundRect({
@ -101,7 +109,7 @@ export function getTextBackgroundRect({
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
background: TextElement["background"];
background: TextBackground;
fontSizeRatio?: number;
}): TextRect | null {
if (!isTextBackgroundVisible({ background })) {
@ -132,7 +140,7 @@ export function getTextVisualRect({
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
background: TextElement["background"];
background: TextBackground;
fontSizeRatio?: number;
}): TextRect {
const textRect = getTextRect({ textAlign, block });

View File

@ -13,6 +13,7 @@ import type {
CreateStickerElement,
CreateUploadAudioElement,
CreateLibraryAudioElement,
TextBackground,
TextElement,
TimelineElement,
TimelineTrack,
@ -46,7 +47,7 @@ export function isVisualElement(
export function canElementBeHidden(
element: TimelineElement,
): element is VisualElement {
return element.type !== "audio";
return isVisualElement(element);
}
export function hasMediaId(
@ -127,13 +128,30 @@ export function wouldElementOverlap({
endTime: number;
excludeElementId?: string;
}): boolean {
return elements.some((el) => {
if (excludeElementId && el.id === excludeElementId) return false;
const elEnd = el.startTime + el.duration;
return startTime < elEnd && endTime > el.startTime;
return elements.some((element) => {
if (excludeElementId && element.id === excludeElementId) return false;
const elementEnd = element.startTime + element.duration;
return startTime < elementEnd && endTime > element.startTime;
});
}
function buildTextBackground(
raw: Partial<TextBackground> | undefined,
): TextBackground {
const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color;
const enabled =
typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent";
return {
enabled,
color,
cornerRadius: raw?.cornerRadius,
paddingX: raw?.paddingX,
paddingY: raw?.paddingY,
offsetX: raw?.offsetX,
offsetY: raw?.offsetY,
};
}
export function buildTextElement({
raw,
startTime,
@ -157,14 +175,7 @@ export function buildTextElement({
: DEFAULT_TEXT_ELEMENT.fontSize,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
background: {
color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
cornerRadius: t.background?.cornerRadius,
paddingX: t.background?.paddingX,
paddingY: t.background?.paddingY,
offsetX: t.background?.offsetX,
offsetY: t.background?.offsetY,
},
background: buildTextBackground(t.background),
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,

View File

@ -1,10 +1,13 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
} from "@/constants/text-constants";
import {
getMetricAscent,
@ -17,6 +20,10 @@ import {
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
import { getEffect } from "@/lib/effects";
import { webglEffectRenderer } from "../webgl-effect-renderer";
import { clamp } from "@/utils/math";
function scaleFontSize({
fontSize,
@ -32,6 +39,9 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`;
}
const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
function drawTextDecoration({
ctx,
textDecoration,
@ -51,7 +61,7 @@ function drawTextDecoration({
}): void {
if (textDecoration === "none" || !textDecoration) return;
const thickness = Math.max(1, scaledFontSize * 0.07);
const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO);
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
@ -65,7 +75,7 @@ function drawTextDecoration({
}
if (textDecoration === "line-through") {
const strikeY = lineY - (ascent - descent) * 0.35;
const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
}
}
@ -89,8 +99,6 @@ export class TextNode extends BaseNode<TextNodeParams> {
return;
}
renderer.context.save();
const localTime = getElementLocalTime({
timelineTime: time,
elementStartTime: this.params.startTime,
@ -106,15 +114,10 @@ export class TextNode extends BaseNode<TextNodeParams> {
animations: this.params.animations,
localTime,
});
const x = transform.position.x + this.params.canvasCenter.x;
const y = transform.position.y + this.params.canvasCenter.y;
renderer.context.translate(x, y);
renderer.context.scale(transform.scale, transform.scale);
if (transform.rotate) {
renderer.context.rotate((transform.rotate * Math.PI) / 180);
}
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal";
const scaledFontSize = scaleFontSize({
@ -122,83 +125,147 @@ export class TextNode extends BaseNode<TextNodeParams> {
canvasHeight: this.params.canvasHeight,
});
const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
renderer.context.textAlign = this.params.textAlign;
renderer.context.fillStyle = this.params.color;
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
const letterSpacing = this.params.letterSpacing ?? 0;
const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
if ("letterSpacing" in renderer.context) {
(
renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
).letterSpacing = `${letterSpacing}px`;
}
const lines = this.params.content.split("\n");
const lineHeightPx = scaledFontSize * lineHeight;
const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const baseline = this.params.textBaseline ?? "middle";
renderer.context.textBaseline = baseline;
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
const lineCount = lines.length;
const block = measureTextBlock({
lineMetrics,
lineHeightPx,
fallbackFontSize: scaledFontSize,
});
const prevAlpha = renderer.context.globalAlpha;
renderer.context.globalCompositeOperation = (
const blendMode = (
this.params.blendMode && this.params.blendMode !== "normal"
? this.params.blendMode
: "source-over"
) as GlobalCompositeOperation;
renderer.context.globalAlpha = opacity;
if (
this.params.background.color &&
this.params.background.color !== "transparent" &&
lineCount > 0
) {
const { color, cornerRadius = 0 } = this.params.background;
const backgroundRect = getTextBackgroundRect({
textAlign: this.params.textAlign,
block,
background: this.params.background,
fontSizeRatio,
});
if (backgroundRect) {
renderer.context.fillStyle = color;
renderer.context.beginPath();
renderer.context.roundRect(
backgroundRect.left,
backgroundRect.top,
backgroundRect.width,
backgroundRect.height,
cornerRadius,
);
renderer.context.fill();
renderer.context.fillStyle = this.params.color;
renderer.context.save();
renderer.context.font = fontString;
renderer.context.textBaseline = baseline;
if ("letterSpacing" in renderer.context) {
(renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
}
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
renderer.context.restore();
const lineCount = lines.length;
const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize });
const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
ctx.font = fontString;
ctx.textAlign = this.params.textAlign;
ctx.textBaseline = baseline;
ctx.fillStyle = this.params.color;
if ("letterSpacing" in ctx) {
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
}
if (
this.params.background.enabled &&
this.params.background.color &&
this.params.background.color !== "transparent" &&
lineCount > 0
) {
const { color, cornerRadius = 0 } = this.params.background;
const backgroundRect = getTextBackgroundRect({
textAlign: this.params.textAlign,
block,
background: this.params.background,
fontSizeRatio,
});
if (backgroundRect) {
const p = clamp({ value: cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
ctx.fill();
ctx.fillStyle = this.params.color;
}
}
for (let i = 0; i < lineCount; i++) {
const lineY = i * lineHeightPx - block.visualCenterOffset;
ctx.fillText(lines[i], 0, lineY);
drawTextDecoration({
ctx,
textDecoration: this.params.textDecoration ?? "none",
lineWidth: lineMetrics[i].width,
lineY,
metrics: lineMetrics[i],
scaledFontSize,
textAlign: this.params.textAlign,
});
}
};
const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
ctx.translate(x, y);
ctx.scale(transform.scale, transform.scale);
if (transform.rotate) {
ctx.rotate((transform.rotate * Math.PI) / 180);
}
};
const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? [];
if (enabledEffects.length === 0) {
renderer.context.save();
applyTransform(renderer.context);
renderer.context.globalCompositeOperation = blendMode;
renderer.context.globalAlpha = opacity;
drawContent(renderer.context);
renderer.context.restore();
return;
}
for (let i = 0; i < lineCount; i++) {
const y = i * lineHeightPx - block.visualCenterOffset;
renderer.context.fillText(lines[i], 0, y);
drawTextDecoration({
ctx: renderer.context,
textDecoration: this.params.textDecoration ?? "none",
lineWidth: lineMetrics[i].width,
lineY: y,
metrics: lineMetrics[i],
scaledFontSize,
textAlign: this.params.textAlign,
// Effects path: render text to a same-size offscreen canvas so the blur
// can spread into the surrounding transparent area without hard clipping.
const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height });
const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
if (!offscreenCtx) {
renderer.context.save();
applyTransform(renderer.context);
renderer.context.globalCompositeOperation = blendMode;
renderer.context.globalAlpha = opacity;
drawContent(renderer.context);
renderer.context.restore();
return;
}
offscreenCtx.save();
applyTransform(offscreenCtx);
drawContent(offscreenCtx);
offscreenCtx.restore();
let currentSource: CanvasImageSource = offscreen;
for (const effect of enabledEffects) {
const resolvedParams = resolveEffectParamsAtTime({
effect,
animations: this.params.animations,
localTime,
});
const definition = getEffect({ effectType: effect.type });
const passes = definition.renderer.passes.map((pass) => ({
fragmentShader: pass.fragmentShader,
uniforms: pass.uniforms({
effectParams: resolvedParams,
width: renderer.width,
height: renderer.height,
}),
}));
currentSource = webglEffectRenderer.applyEffect({
source: currentSource,
width: renderer.width,
height: renderer.height,
passes,
});
}
renderer.context.globalAlpha = prevAlpha;
renderer.context.save();
renderer.context.globalCompositeOperation = blendMode;
renderer.context.globalAlpha = opacity;
renderer.context.drawImage(currentSource, 0, 0);
renderer.context.restore();
}
}

View File

@ -10,6 +10,7 @@ import {
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { getEffect } from "@/lib/effects";
import { webglEffectRenderer } from "../webgl-effect-renderer";
@ -127,11 +128,16 @@ export abstract class VisualNode<
let currentResult: CanvasImageSource = elementCanvas;
for (const effect of enabledEffects) {
const resolvedParams = resolveEffectParamsAtTime({
effect,
animations: this.params.animations,
localTime: animationLocalTime,
});
const definition = getEffect({ effectType: effect.type });
const passes = definition.renderer.passes.map((pass) => ({
fragmentShader: pass.fragmentShader,
uniforms: pass.uniforms({
effectParams: effect.params,
effectParams: resolvedParams,
width: scaledWidth,
height: scaledHeight,
}),

View File

@ -0,0 +1,175 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
const v8ProjectWithText = {
id: "project-v8-text",
version: 8,
metadata: {
id: "project-v8-text",
name: "V8 Project with Text",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
currentSceneId: "scene-main",
scenes: [
{
id: "scene-main",
name: "Main scene",
isMain: true,
tracks: [
{
id: "track-text",
type: "text",
name: "Text Track",
hidden: false,
elements: [
{
id: "el-1",
type: "text",
content: "With color",
startTime: 0,
duration: 5,
background: {
color: "#ff0000",
cornerRadius: 0,
paddingX: 8,
paddingY: 4,
},
},
{
id: "el-2",
type: "text",
content: "Transparent",
startTime: 5,
duration: 5,
background: {
color: "transparent",
paddingX: 30,
paddingY: 42,
},
},
],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
describe("V8 to V9 Migration", () => {
test("adds background.enabled from color (transparent => false, otherwise true)", () => {
const result = transformProjectV8ToV9({ project: v8ProjectWithText });
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
const track = (
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
)[0].tracks[0];
const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
expect(elements[0].background.enabled).toBe(true);
expect(elements[0].background.color).toBe("#ff0000");
expect(elements[1].background.enabled).toBe(false);
expect(elements[1].background.color).toBe("transparent");
});
test("preserves existing background.enabled if already present", () => {
const projectWithEnabled = {
...v8ProjectWithText,
scenes: [
{
...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
tracks: [
{
id: "track-text",
type: "text",
name: "Text Track",
hidden: false,
elements: [
{
id: "el-1",
type: "text",
content: "Already has enabled",
startTime: 0,
duration: 5,
background: {
enabled: false,
color: "#00ff00",
},
},
],
},
],
},
],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
const result = transformProjectV8ToV9({ project: projectWithEnabled });
expect(result.skipped).toBe(false);
const elements = (
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
)[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
expect(elements[0].background.enabled).toBe(false);
});
test("skips non-text elements and tracks", () => {
const projectWithVideoOnly = {
...v8ProjectWithText,
scenes: [
{
id: "scene-main",
name: "Main scene",
isMain: true,
tracks: [
{
id: "track-video",
type: "video",
name: "Video Track",
isMain: true,
elements: [],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
const result = transformProjectV8ToV9({ project: projectWithVideoOnly });
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
});
test("skips projects that are already v9", () => {
const result = transformProjectV8ToV9({
project: { ...v8ProjectWithText, version: 9 },
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v9");
});
test("skips projects with no id", () => {
const result = transformProjectV8ToV9({
project: {
version: 8,
scenes: [],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"],
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
});

View File

@ -7,10 +7,11 @@ import { V4toV5Migration } from "./v4-to-v5";
import { V5toV6Migration } from "./v5-to-v6";
import { V6toV7Migration } from "./v6-to-v7";
import { V7toV8Migration } from "./v7-to-v8";
import { V8toV9Migration } from "./v8-to-v9";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 8;
export const CURRENT_PROJECT_VERSION = 9;
export const migrations = [
new V0toV1Migration(),
@ -21,4 +22,5 @@ export const migrations = [
new V5toV6Migration(),
new V6toV7Migration(),
new V7toV8Migration(),
new V8toV9Migration(),
];

View File

@ -388,7 +388,7 @@ async function transformMediaTrack({
);
const validElements = transformedElements.filter(
(el): el is VideoElement | ImageElement => el !== null,
(element): element is VideoElement | ImageElement => element !== null,
);
return {
@ -450,17 +450,18 @@ function transformTextTrack({
value: textElement.color,
fallback: "#000000",
}),
background: {
color: getStringValue({
value: textElement.backgroundColor,
fallback: "transparent",
}),
cornerRadius: 0,
paddingX: 8,
paddingY: 4,
offsetX: 0,
offsetY: 0,
},
background: {
enabled: false,
color: getStringValue({
value: textElement.backgroundColor,
fallback: "transparent",
}),
cornerRadius: 0,
paddingX: 8,
paddingY: 4,
offsetX: 0,
offsetY: 0,
},
textAlign: (getStringValue({
value: textElement.textAlign,
fallback: "left",
@ -486,7 +487,7 @@ function transformTextTrack({
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
};
})
.filter((el): el is TextElement => el !== null);
.filter((element): element is TextElement => element !== null);
return {
id: getStringValue({ value: track.id, fallback: "" }),
@ -529,7 +530,7 @@ function transformAudioTrack({
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
};
})
.filter((el): el is AudioElement => el !== null);
.filter((element): element is AudioElement => element !== null);
return {
id: getStringValue({ value: track.id, fallback: "" }),

View File

@ -0,0 +1,99 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV8ToV9({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV9Project({ project })) {
return { project, skipped: true, reason: "already v9" };
}
const migratedProject = migrateProjectTextElements({ project });
return {
project: { ...migratedProject, version: 9 },
skipped: false,
};
}
function migrateProjectTextElements({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
let hasChanges = false;
const migratedScenes = scenesValue.map((scene) => {
const migrated = migrateSceneTextElements({ scene });
if (migrated !== scene) hasChanges = true;
return migrated;
});
if (!hasChanges) return project;
return { ...project, scenes: migratedScenes };
}
function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
let hasChanges = false;
const migratedTracks = tracksValue.map((track) => {
const migrated = migrateTrackTextElements({ track });
if (migrated !== track) hasChanges = true;
return migrated;
});
if (!hasChanges) return scene;
return { ...scene, tracks: migratedTracks };
}
function migrateTrackTextElements({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
if (track.type !== "text") return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
let hasChanges = false;
const migratedElements = elementsValue.map((element) => {
const migrated = migrateTextElement({ element });
if (migrated !== element) hasChanges = true;
return migrated;
});
if (!hasChanges) return track;
return { ...track, elements: migratedElements };
}
function migrateTextElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
if (element.type !== "text") return element;
const bg = element.background;
if (!isRecord(bg)) return element;
if (typeof bg.enabled === "boolean") return element;
const color = typeof bg.color === "string" ? bg.color : "transparent";
const enabled = color !== "transparent";
return {
...element,
background: { ...bg, enabled },
};
}
function isV9Project({ project }: { project: ProjectRecord }): boolean {
return typeof project.version === "number" && project.version >= 9;
}

View File

@ -0,0 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV8ToV9 } from "./transformers/v8-to-v9";
export class V8toV9Migration extends StorageMigration {
from = 8;
to = 9;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV8ToV9({ project });
}
}

View File

@ -1,5 +1,6 @@
import type { ElementType } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import {
ArrowRightDoubleIcon,
ClosedCaptionIcon,
@ -81,7 +82,9 @@ export const tabs = {
{ icon: ElementType<{ className?: string }>; label: string }
>;
type MediaViewMode = "grid" | "list";
export type MediaViewMode = "grid" | "list";
export type MediaSortKey = "name" | "type" | "duration" | "size";
export type MediaSortOrder = "asc" | "desc";
interface AssetsPanelStore {
activeTab: Tab;
@ -93,15 +96,34 @@ interface AssetsPanelStore {
/* Media */
mediaViewMode: MediaViewMode;
setMediaViewMode: (mode: MediaViewMode) => void;
mediaSortBy: MediaSortKey;
mediaSortOrder: MediaSortOrder;
setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void;
}
export const useAssetsPanelStore = create<AssetsPanelStore>((set) => ({
activeTab: "media",
setActiveTab: (tab) => set({ activeTab: tab }),
highlightMediaId: null,
requestRevealMedia: (mediaId) =>
set({ activeTab: "media", highlightMediaId: mediaId }),
clearHighlight: () => set({ highlightMediaId: null }),
mediaViewMode: "grid",
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
}));
export const useAssetsPanelStore = create<AssetsPanelStore>()(
persist(
(set) => ({
activeTab: "media",
setActiveTab: (tab) => set({ activeTab: tab }),
highlightMediaId: null,
requestRevealMedia: (mediaId) =>
set({ activeTab: "media", highlightMediaId: mediaId }),
clearHighlight: () => set({ highlightMediaId: null }),
mediaViewMode: "grid",
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
mediaSortBy: "name",
mediaSortOrder: "asc",
setMediaSort: (key, order) =>
set({ mediaSortBy: key, mediaSortOrder: order }),
}),
{
name: "assets-panel",
partialize: (state) => ({
mediaViewMode: state.mediaViewMode,
mediaSortBy: state.mediaSortBy,
mediaSortOrder: state.mediaSortOrder,
}),
},
),
);

View File

@ -39,8 +39,8 @@ interface KeybindingsState {
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
}
function isDOMElement(el: EventTarget | null): el is HTMLElement {
return !!el && (el instanceof Element || el instanceof HTMLElement);
function isDOMElement(element: EventTarget | null): element is HTMLElement {
return element instanceof HTMLElement;
}
export const useKeybindingsStore = create<KeybindingsState>()(
@ -90,11 +90,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
set({ keybindingsEnabled: false });
},
importKeybindings: (config: KeybindingConfig) => {
// Validate all keys and actions
for (const [key] of Object.entries(config)) {
// Validate the key format
if (typeof key !== "string" || key.length === 0) {
importKeybindings: (config: KeybindingConfig) => {
for (const [key] of Object.entries(config)) {
if (typeof key !== "string" || key.length === 0) {
throw new Error(`Invalid key format: ${key}`);
}
}
@ -153,20 +151,13 @@ export const useKeybindingsStore = create<KeybindingsState>()(
),
);
// Utility functions
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
const target = ev.target;
// We may or may not have a modifier key
const modifierKey = getActiveModifier(ev);
// We will always have a non-modifier key
const key = getPressedKey(ev);
if (!key) return null;
// All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
if (modifierKey) {
// If the modifier is shift and the target is an input, we ignore
if (
modifierKey === "shift" &&
isDOMElement(target) &&
@ -178,61 +169,44 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
return `${modifierKey}+${key}` as ShortcutKey;
}
// no modifier key here then we do not do anything while on input
if (
isDOMElement(target) &&
isTypableDOMElement({ element: target as HTMLElement })
)
if (isDOMElement(target) && isTypableDOMElement({ element: target as HTMLElement }))
return null;
// single key while not input
return `${key}` as ShortcutKey;
}
function getPressedKey(ev: KeyboardEvent): string | null {
// Sometimes the property code is not available on the KeyboardEvent object
const key = (ev.key ?? "").toLowerCase();
const code = ev.code ?? "";
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
return "space";
// Check arrow keys
if (key.startsWith("arrow")) {
return key.slice(5);
}
if (key.startsWith("arrow")) return key.slice(5);
// Check for special keys
if (key === "escape") return "escape";
if (key === "tab") return "tab";
if (key === "home") return "home";
if (key === "end") return "end";
if (key === "delete") return "delete";
if (key === "backspace") return "backspace";
// Check letter keys
if (code.startsWith("Key")) {
const letter = code.slice(3).toLowerCase();
if (letter.length === 1 && letter >= "a" && letter <= "z") {
return letter;
}
if (letter.length === 1 && letter >= "a" && letter <= "z") return letter;
}
// Check number keys using physical position for AZERTY support
// Use physical key position for AZERTY and other non-QWERTY layouts
if (code.startsWith("Digit")) {
const digit = code.slice(5);
if (digit.length === 1 && digit >= "0" && digit <= "9") {
return digit;
}
if (digit.length === 1 && digit >= "0" && digit <= "9") return digit;
}
// Fallback for other layouts
const isDigit = key.length === 1 && key >= "0" && key <= "9";
if (isDigit) return key;
// Check if slash, period or enter
if (key === "/" || key === "." || key === "enter") return key;
// If no other cases match, this is not a valid key
return null;
}
@ -243,8 +217,6 @@ function getActiveModifier(ev: KeyboardEvent): string | null {
shift: ev.shiftKey,
};
// active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift
// modiferKeys object's keys are sorted to match the above order
const activeModifier = Object.keys(modifierKeys)
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
.join("+");

View File

@ -1,18 +1,16 @@
import { v2ToV3 } from "./v2-to-v3";
import { v3ToV4 } from "./v3-to-v4";
import { v4ToV5 } from "./v4-to-v5";
type MigrationFn = ({ state }: { state: unknown }) => unknown;
/**
* key = version we're migrating from
* value = migration function
*/
const migrations: Record<number, MigrationFn> = {
2: v2ToV3,
3: v3ToV4,
4: v4ToV5,
};
export const CURRENT_VERSION = 4;
export const CURRENT_VERSION = 5;
export function runMigrations({
state,

View File

@ -0,0 +1,17 @@
import type { KeybindingConfig } from "@/types/keybinding";
interface V4State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v4ToV5({ state }: { state: unknown }): unknown {
const v4 = state as V4State;
const keybindings = { ...v4.keybindings };
if (!keybindings.escape) {
keybindings.escape = "deselect-all";
}
return { ...v4, keybindings };
}

View File

@ -0,0 +1,19 @@
import { create } from "zustand";
interface ClipEffectsTarget {
elementId: string;
trackId: string;
}
interface PropertiesState {
clipEffectsTarget: ClipEffectsTarget | null;
openClipEffects: ({ elementId, trackId }: ClipEffectsTarget) => void;
closeClipEffects: () => void;
}
export const usePropertiesStore = create<PropertiesState>()((set) => ({
clipEffectsTarget: null,
openClipEffects: ({ elementId, trackId }) =>
set({ clipEffectsTarget: { elementId, trackId } }),
closeClipEffects: () => set({ clipEffectsTarget: null }),
}));

View File

@ -13,12 +13,10 @@ export type AnimationValueKind = "number" | "color" | "discrete";
export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean;
export type NumberKeyframeInterpolation = "linear" | "hold";
export type ColorKeyframeInterpolation = "linear" | "hold";
export type ContinuousKeyframeInterpolation = "linear" | "hold";
export type DiscreteKeyframeInterpolation = "hold";
export type AnimationInterpolation =
| NumberKeyframeInterpolation
| ColorKeyframeInterpolation
| ContinuousKeyframeInterpolation
| DiscreteKeyframeInterpolation;
interface BaseAnimationKeyframe<
@ -32,15 +30,18 @@ interface BaseAnimationKeyframe<
}
export interface NumberKeyframe
extends BaseAnimationKeyframe<number, NumberKeyframeInterpolation> {}
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
export interface ColorKeyframe
extends BaseAnimationKeyframe<string, ColorKeyframeInterpolation> {}
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
export interface DiscreteKeyframe
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
export type AnimationKeyframe = NumberKeyframe | ColorKeyframe | DiscreteKeyframe;
export type AnimationKeyframe =
| NumberKeyframe
| ColorKeyframe
| DiscreteKeyframe;
interface BaseAnimationChannel<
TValueKind extends AnimationValueKind,

View File

@ -9,17 +9,41 @@ export type EffectParamType = "number" | "boolean" | "select" | "color";
export type EffectParamValues = Record<string, number | string | boolean>;
export interface EffectParamDefinition {
interface BaseEffectParamDefinition {
key: string;
label: string;
type: EffectParamType;
default: number | string | boolean;
min?: number;
max?: number;
step?: number;
options?: Array<{ value: string; label: string }>;
}
export interface NumberEffectParamDefinition extends BaseEffectParamDefinition {
type: "number";
default: number;
min: number;
max: number;
step: number;
}
interface BooleanEffectParamDefinition extends BaseEffectParamDefinition {
type: "boolean";
default: boolean;
}
interface SelectEffectParamDefinition extends BaseEffectParamDefinition {
type: "select";
default: string;
options: Array<{ value: string; label: string }>;
}
interface ColorEffectParamDefinition extends BaseEffectParamDefinition {
type: "color";
default: string;
}
export type EffectParamDefinition =
| NumberEffectParamDefinition
| BooleanEffectParamDefinition
| SelectEffectParamDefinition
| ColorEffectParamDefinition;
export interface WebGLEffectPass {
fragmentShader: string;
uniforms(params: {

View File

@ -15,8 +15,6 @@ export interface ExportOptions {
quality: ExportQuality;
fps?: number;
includeAudio?: boolean;
onProgress?: ({ progress }: { progress: number }) => void;
onCancel?: () => boolean;
}
export interface ExportResult {
@ -25,3 +23,9 @@ export interface ExportResult {
error?: string;
cancelled?: boolean;
}
export interface ExportState {
isExporting: boolean;
progress: number;
result: ExportResult | null;
}

View File

@ -118,20 +118,23 @@ export interface ImageElement extends BaseTimelineElement {
effects?: Effect[];
}
export interface TextBackground {
enabled: boolean;
color: string;
cornerRadius?: number;
paddingX?: number;
paddingY?: number;
offsetX?: number;
offsetY?: number;
}
export interface TextElement extends BaseTimelineElement {
type: "text";
content: string;
fontSize: number;
fontFamily: string;
color: string;
background: {
color: string;
cornerRadius?: number;
paddingX?: number;
paddingY?: number;
offsetX?: number;
offsetY?: number;
};
background: TextBackground;
textAlign: "left" | "center" | "right";
fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic";