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
@@ -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>;
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
@@ -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
@@ -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({
@@ -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}
@@ -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"
@@ -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>
@@ -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>
);