delete legacy folder

This commit is contained in:
Maze Winther 2026-04-18 15:35:04 +02:00
parent b1d0b10354
commit 0387bd4c80
16 changed files with 0 additions and 2816 deletions

View File

@ -1,3 +0,0 @@
export function AudioProperties() {
return <div className="space-y-4 p-5">Audio properties</div>;
}

View File

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

View File

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

View File

@ -1,58 +0,0 @@
"use client";
import type { EffectElement } from "@/types/timeline";
import { getEffect } from "@/lib/effects/registry";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
SectionContent,
SectionHeader,
SectionFields,
SectionTitle,
} from "./section";
import { EffectParamField } from "./effect-param-field";
export function EffectProperties({
element,
trackId,
}: {
element: EffectElement;
trackId: string;
}) {
const editor = useEditor();
const definition = getEffect({ effectType: element.effectType });
const previewParam =
({ key }: { key: string }) =>
(value: number | string | boolean) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { params: { ...element.params, [key]: value } },
},
],
});
return (
<Section showTopBorder={false}>
<SectionHeader>
<SectionTitle>{definition.name}</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
{definition.params.map((param) => (
<EffectParamField
key={param.key}
param={param}
value={element.params[param.key] ?? param.default}
onPreview={previewParam({ key: param.key })}
onCommit={() => editor.timeline.commitPreview()}
/>
))}
</SectionFields>
</SectionContent>
</Section>
);
}

View File

@ -1,32 +0,0 @@
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { KeyframeIcon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
export function KeyframeToggle({
isActive,
isDisabled = false,
title,
onToggle,
}: {
isActive: boolean;
isDisabled?: boolean;
title: string;
onToggle: () => void;
}) {
return (
<Button
variant="text"
aria-pressed={isActive}
disabled={isDisabled}
title={title}
onClick={onToggle}
className="[&>svg]:size-3.5 mb-0.5"
>
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(isActive && "text-primary fill-primary")}
/>
</Button>
);
}

View File

@ -1,262 +0,0 @@
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;
toggle: () => void;
collapsible: boolean;
}
const SectionCtx = createContext<SectionContext | null>(null);
function useSectionContext() {
return useContext(SectionCtx);
}
interface SectionProps {
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
sectionKey?: string;
className?: string;
showTopBorder?: boolean;
showBottomBorder?: boolean;
}
export function Section({
children,
collapsible = false,
defaultOpen = true,
sectionKey,
className,
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);
if (sectionKey) sectionExpandedCache.set(sectionKey, next);
};
return (
<SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
<div
className={cn(
"flex flex-col",
showTopBorder && "border-t",
showBottomBorder && "last:border-b",
className,
)}
>
{children}
</div>
</SectionCtx.Provider>
);
}
interface SectionHeaderProps {
children?: React.ReactNode;
trailing?: React.ReactNode;
leading?: React.ReactNode;
actions?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function SectionHeader({
children,
trailing,
leading,
actions,
onClick,
className,
}: SectionHeaderProps) {
const ctx = useSectionContext();
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
const isInteractive = isCollapsible || !!onClick;
const handleClick = isCollapsible ? ctx?.toggle : onClick;
const 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 = (
<>
{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}
</>
);
if (!isInteractive) {
return (
<div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}>
{headerContent}
</div>
);
}
return (
// biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here
<div
role="button"
tabIndex={0}
className={cn(
"flex h-11 w-full cursor-pointer items-center gap-2 px-3.5",
className,
)}
onClick={handleClick}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }}
>
{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(
"cursor-pointer text-sm font-medium",
isOpen ? "text-foreground" : "text-muted-foreground",
className,
)}
onClick={onClick}
>
{children}
</button>
);
}
return (
<span
className={cn(
"text-sm font-medium",
isOpen ? "text-foreground" : "text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
export function SectionFields({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-3.5", className)}>{children}</div>
);
}
export function SectionField({
label,
beforeLabel,
children,
className,
}: {
label: string;
beforeLabel?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex h-4 items-center gap-1.5">
{beforeLabel}
<Label>{label}</Label>
</div>
{children}
</div>
);
}
export function SectionContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const ctx = useSectionContext();
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
if (isCollapsible) {
return (
<div
className={cn(
"grid transition-[grid-template-rows] duration-100 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className={cn("p-4 pt-0", className)}>{children}</div>
</div>
</div>
);
}
return <div className={cn("p-4 pt-0", className)}>{children}</div>;
}

View File

@ -1,732 +0,0 @@
import { Textarea } from "@/components/ui/textarea";
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,
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,
DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft";
import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property";
import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property";
import { useElementPlayhead } from "./hooks/use-element-playhead";
import { TransformSection, BlendingSection } from "./sections";
import { KeyframeToggle } from "./keyframe-toggle";
import { isPropertyAtDefault } from "./sections/transform";
import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import {
TextFontIcon,
ViewIcon,
ViewOffSlashIcon,
} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
import { cn } from "@/utils/ui";
export function TextProperties({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
return (
<div className="flex h-full flex-col">
<ContentSection element={element} trackId={trackId} />
<TransformSection element={element} trackId={trackId} />
<BlendingSection element={element} trackId={trackId} />
<TypographySection element={element} trackId={trackId} />
<SpacingSection element={element} trackId={trackId} />
<BackgroundSection element={element} trackId={trackId} />
</div>
);
}
function ContentSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const content = usePropertyDraft({
displayValue: element.content,
parse: (input) => input,
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { content: value } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:content" showTopBorder={false}>
<SectionHeader>
<SectionTitle>Content</SectionTitle>
</SectionHeader>
<SectionContent>
<Textarea
placeholder="Name"
value={content.displayValue}
className="min-h-20"
onFocus={content.onFocus}
onChange={content.onChange}
onBlur={content.onBlur}
/>
</SectionContent>
</Section>
);
}
function TypographySection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedTextColor = resolveColorAtTime({
baseColor: element.color,
animations: element.animations,
propertyPath: "color",
localTime,
});
const textColor = useKeyframedColorProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "color",
localTime,
isPlayheadWithinElementRange,
resolvedColor: resolvedTextColor,
buildBaseUpdates: ({ value }) => ({ color: value }),
});
const fontSize = usePropertyDraft({
displayValue: element.fontSize.toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { fontSize: value } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:typography">
<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"
beforeLabel={
<KeyframeToggle
isActive={textColor.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle text color keyframe"
onToggle={textColor.toggleKeyframe}
/>
}
>
<ColorPicker
value={uppercase({
string: resolvedTextColor.replace("#", ""),
})}
onChange={(color) => textColor.onChange({ color: `#${color}` })}
onChangeEnd={textColor.onChangeEnd}
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}
function SpacingSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const letterSpacing = usePropertyDraft({
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 } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const lineHeight = usePropertyDraft({
displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
parse: (input) => {
const parsed = parseFloat(input);
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 } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<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>
);
}
function BackgroundSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedBgColor = resolveColorAtTime({
baseColor: element.background.color,
animations: element.animations,
propertyPath: "background.color",
localTime,
});
const bgColor = useKeyframedColorProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "background.color",
localTime,
isPlayheadWithinElementRange,
resolvedColor: resolvedBgColor,
buildBaseUpdates: ({ value }) => ({
background: { ...element.background, color: value },
}),
});
const bg = element.background;
const resolvedPaddingX = resolveNumberAtTime({
baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
animations: element.animations,
propertyPath: "background.paddingX",
localTime,
});
const resolvedPaddingY = resolveNumberAtTime({
baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
animations: element.animations,
propertyPath: "background.paddingY",
localTime,
});
const resolvedOffsetX = resolveNumberAtTime({
baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
animations: element.animations,
propertyPath: "background.offsetX",
localTime,
});
const resolvedOffsetY = resolveNumberAtTime({
baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
animations: element.animations,
propertyPath: "background.offsetY",
localTime,
});
const resolvedCornerRadius = resolveNumberAtTime({
baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
animations: element.animations,
propertyPath: "background.cornerRadius",
localTime,
});
const paddingX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "background.paddingX",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedPaddingX).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingX,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingX: value },
}),
});
const paddingY = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "background.paddingY",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedPaddingY).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingY,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingY: value },
}),
});
const offsetX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "background.offsetX",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedOffsetX).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
valueAtPlayhead: resolvedOffsetX,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetX: value },
}),
});
const offsetY = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "background.offsetY",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedOffsetY).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
valueAtPlayhead: resolvedOffsetY,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetY: value },
}),
});
const cornerRadius = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "background.cornerRadius",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedCornerRadius).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX });
},
valueAtPlayhead: resolvedCornerRadius,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, cornerRadius: value },
}),
});
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
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"
beforeLabel={
<KeyframeToggle
isActive={bgColor.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle background color keyframe"
onToggle={bgColor.toggleKeyframe}
/>
}
>
<ColorPicker
value={
!element.background.enabled ||
element.background.color === "transparent"
? lastSelectedColor.current.replace("#", "")
: resolvedBgColor.replace("#", "")
}
onChange={(color) => {
const hexColor = `#${color}`;
if (color !== "transparent") {
lastSelectedColor.current = hexColor;
}
bgColor.onChange({ color: hexColor });
}}
onChangeEnd={bgColor.onChangeEnd}
/>
</SectionField>
<div className="flex items-start gap-2">
<SectionField
label="Width"
className="w-1/2"
beforeLabel={
<KeyframeToggle
isActive={paddingX.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle background width keyframe"
onToggle={paddingX.toggleKeyframe}
/>
}
>
<NumberField
icon="W"
value={paddingX.displayValue}
min={0}
onFocus={paddingX.onFocus}
onChange={paddingX.onChange}
onBlur={paddingX.onBlur}
onScrub={paddingX.scrubTo}
onScrubEnd={paddingX.commitScrub}
onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedPaddingX,
staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
})}
/>
</SectionField>
<SectionField
label="Height"
className="w-1/2"
beforeLabel={
<KeyframeToggle
isActive={paddingY.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle background height keyframe"
onToggle={paddingY.toggleKeyframe}
/>
}
>
<NumberField
icon="H"
value={paddingY.displayValue}
min={0}
onFocus={paddingY.onFocus}
onChange={paddingY.onChange}
onBlur={paddingY.onBlur}
onScrub={paddingY.scrubTo}
onScrubEnd={paddingY.commitScrub}
onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedPaddingY,
staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY,
})}
/>
</SectionField>
</div>
<div className="flex items-start gap-2">
<SectionField
label="X-offset"
className="w-1/2"
beforeLabel={
<KeyframeToggle
isActive={offsetX.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle x-offset keyframe"
onToggle={offsetX.toggleKeyframe}
/>
}
>
<NumberField
icon="X"
value={offsetX.displayValue}
onFocus={offsetX.onFocus}
onChange={offsetX.onChange}
onBlur={offsetX.onBlur}
onScrub={offsetX.scrubTo}
onScrubEnd={offsetX.commitScrub}
onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOffsetX,
staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX,
})}
/>
</SectionField>
<SectionField
label="Y-offset"
className="w-1/2"
beforeLabel={
<KeyframeToggle
isActive={offsetY.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle y-offset keyframe"
onToggle={offsetY.toggleKeyframe}
/>
}
>
<NumberField
icon="Y"
value={offsetY.displayValue}
onFocus={offsetY.onFocus}
onChange={offsetY.onChange}
onBlur={offsetY.onBlur}
onScrub={offsetY.scrubTo}
onScrubEnd={offsetY.commitScrub}
onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOffsetY,
staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY,
})}
/>
</SectionField>
</div>
<SectionField
label="Corner radius"
beforeLabel={
<KeyframeToggle
isActive={cornerRadius.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle corner radius keyframe"
onToggle={cornerRadius.toggleKeyframe}
/>
}
>
<NumberField
icon="R"
value={cornerRadius.displayValue}
min={CORNER_RADIUS_MIN}
max={CORNER_RADIUS_MAX}
onFocus={cornerRadius.onFocus}
onChange={cornerRadius.onChange}
onBlur={cornerRadius.onBlur}
onScrub={cornerRadius.scrubTo}
onScrubEnd={cornerRadius.commitScrub}
onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedCornerRadius,
staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
defaultValue: CORNER_RADIUS_MIN,
})}
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}

View File

@ -1,25 +0,0 @@
import type {
ImageElement,
StickerElement,
VideoElement,
} from "@/types/timeline";
import { BlendingSection, TransformSection } from "./sections";
export function VideoProperties({
element,
trackId,
}: {
element: VideoElement | ImageElement | StickerElement;
trackId: string;
}) {
return (
<div className="flex h-full flex-col">
<TransformSection
element={element}
trackId={trackId}
showTopBorder={false}
/>
<BlendingSection element={element} trackId={trackId} />
</div>
);
}

View File

@ -1,222 +0,0 @@
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import {
DEFAULT_BLEND_MODE,
DEFAULT_OPACITY,
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BlendMode } from "@/types/rendering";
import type { ElementType } from "@/types/timeline";
import type { ElementAnimations } from "@/types/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
import { KeyframeToggle } from "../keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation";
import { isPropertyAtDefault } from "./transform";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
startTime: number;
duration: number;
animations?: ElementAnimations;
};
const BLEND_MODE_GROUPS = [
[{ value: "normal", label: "Normal" }],
[
{ value: "darken", label: "Darken" },
{ value: "multiply", label: "Multiply" },
{ value: "color-burn", label: "Color Burn" },
],
[
{ value: "lighten", label: "Lighten" },
{ value: "screen", label: "Screen" },
{ value: "plus-lighter", label: "Plus Lighter" },
{ value: "color-dodge", label: "Color Dodge" },
],
[
{ value: "overlay", label: "Overlay" },
{ value: "soft-light", label: "Soft Light" },
{ value: "hard-light", label: "Hard Light" },
],
[
{ value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" },
],
[
{ value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" },
],
];
export function BlendingSection({
element,
trackId,
}: {
element: BlendingElement;
trackId: string;
}) {
const editor = useEditor();
const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
const didSelectRef = useRef(false);
const committedBlendModeRef = useRef(blendMode);
if (!editor.timeline.isPreviewActive()) {
committedBlendModeRef.current = blendMode;
}
const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
],
});
const commitBlendMode = (value: string) => {
if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview();
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { blendMode: value as BlendMode },
},
],
});
}
didSelectRef.current = true;
};
const handleBlendModeOpenChange = (isOpen: boolean) => {
if (!isOpen) {
if (!didSelectRef.current) editor.timeline.discardPreview();
didSelectRef.current = false;
}
};
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedOpacity = resolveOpacityAtTime({
baseOpacity: element.opacity,
animations: element.animations,
localTime,
});
const opacity = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "opacity",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedOpacity * 100).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
valueAtPlayhead: resolvedOpacity,
buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
<SectionField
label="Opacity"
className="w-1/2"
beforeLabel={
<KeyframeToggle
isActive={opacity.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle opacity keyframe"
onToggle={opacity.toggleKeyframe}
/>
}
>
<NumberField
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOpacity,
staticValue: element.opacity,
defaultValue: DEFAULT_OPACITY,
})}
dragSensitivity="slow"
/>
</SectionField>
<SectionField label="Blend mode" className="w-1/2">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36">
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode({ value: option.value as BlendMode })
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</SectionField>
</div>
</SectionContent>
</Section>
);
}

View File

@ -1,2 +0,0 @@
export * from "./transform";
export * from "./blending";

View File

@ -1,382 +0,0 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
import { clamp, isNearlyEqual } from "@/utils/math";
import type { AnimationPropertyPath } from "@/types/animation";
import type { VisualElement } from "@/types/timeline";
import {
Section,
SectionContent,
SectionField,
SectionFields,
SectionHeader,
SectionTitle,
} from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowExpandIcon,
Link05Icon,
RotateClockwiseIcon,
} from "@hugeicons/core-free-icons";
import { useState } from "react";
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
import { KeyframeToggle } from "../keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
export function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
export function isPropertyAtDefault({
hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue,
staticValue,
defaultValue,
}: {
hasAnimatedKeyframes: boolean;
isPlayheadWithinElementRange: boolean;
resolvedValue: number;
staticValue: number;
defaultValue: number;
}): boolean {
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
return isNearlyEqual({
leftValue: resolvedValue,
rightValue: defaultValue,
});
}
return staticValue === defaultValue;
}
export function TransformSection({
element,
trackId,
showTopBorder = true,
}: {
element: VisualElement;
trackId: string;
showTopBorder?: boolean;
}) {
const editor = useEditor();
const [isScaleLocked, setIsScaleLocked] = useState(false);
const playheadTime = editor.playback.getCurrentTime();
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const isPlayheadWithinElementRange =
playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
const positionX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.position.x",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.position.x).toString(),
parse: (input) => parseNumericInput({ input }),
valueAtPlayhead: resolvedTransform.position.x,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
position: {
...element.transform.position,
x: value,
},
},
}),
});
const positionY = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.position.y",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.position.y).toString(),
parse: (input) => parseNumericInput({ input }),
valueAtPlayhead: resolvedTransform.position.y,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
position: {
...element.transform.position,
y: value,
},
},
}),
});
const scale = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.scale",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.scale * 100).toString(),
parse: (input) => {
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return Math.max(parsed, 1) / 100;
},
valueAtPlayhead: resolvedTransform.scale,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
scale: value,
},
}),
});
const scaleFieldProps = {
className: "flex-1",
value: scale.displayValue,
onFocus: scale.onFocus,
onChange: scale.onChange,
onBlur: scale.onBlur,
dragSensitivity: "slow" as const,
onScrub: scale.scrubTo,
onScrubEnd: scale.commitScrub,
onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
isDefault: isPropertyAtDefault({
hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.scale,
staticValue: element.transform.scale,
defaultValue: DEFAULT_TRANSFORM.scale,
}),
};
const rotation = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.rotate",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.rotate).toString(),
parse: (input) => {
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return clamp({ value: parsed, min: -360, max: 360 });
},
valueAtPlayhead: resolvedTransform.rotate,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
rotate: value,
},
}),
});
const hasPositionKeyframe =
positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
const togglePositionKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
const keyframesToRemove: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}> = [];
if (positionX.keyframeIdAtTime) {
keyframesToRemove.push({
trackId,
elementId: element.id,
propertyPath: "transform.position.x" as const,
keyframeId: positionX.keyframeIdAtTime,
});
}
if (positionY.keyframeIdAtTime) {
keyframesToRemove.push({
trackId,
elementId: element.id,
propertyPath: "transform.position.y" as const,
keyframeId: positionY.keyframeIdAtTime,
});
}
editor.timeline.removeKeyframes({
keyframes: keyframesToRemove,
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId: element.id,
propertyPath: "transform.position.x",
time: localTime,
value: resolvedTransform.position.x,
},
{
trackId,
elementId: element.id,
propertyPath: "transform.position.y",
time: localTime,
value: resolvedTransform.position.y,
},
],
});
};
return (
<Section
collapsible
sectionKey={`${element.type}:transform`}
showTopBorder={showTopBorder}
>
<SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
<SectionContent>
<SectionFields>
<SectionField
label="Scale"
beforeLabel={
<KeyframeToggle
isActive={scale.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle scale keyframe"
onToggle={scale.toggleKeyframe}
/>
}
>
<div className="flex items-center gap-2">
{isScaleLocked ? (
<>
<NumberField icon="W" {...scaleFieldProps} />
<NumberField icon="H" {...scaleFieldProps} />
</>
) : (
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldProps}
className="flex-1"
/>
)}
<Button
type="button"
variant={isScaleLocked ? "secondary" : "ghost"}
size="icon"
aria-pressed={isScaleLocked}
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
>
<HugeiconsIcon icon={Link05Icon} />
</Button>
</div>
</SectionField>
<SectionField
label="Position"
beforeLabel={
<KeyframeToggle
isActive={hasPositionKeyframe}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle position keyframe"
onToggle={togglePositionKeyframe}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.x,
staticValue: element.transform.position.x,
defaultValue: DEFAULT_TRANSFORM.position.x,
})}
/>
<NumberField
icon="Y"
className="flex-1"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.y,
staticValue: element.transform.position.y,
defaultValue: DEFAULT_TRANSFORM.position.y,
})}
/>
</div>
</SectionField>
<SectionField
label="Rotation"
beforeLabel={
<KeyframeToggle
isActive={rotation.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle rotation keyframe"
onToggle={rotation.toggleKeyframe}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-none"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.rotate,
staticValue: element.transform.rotate,
defaultValue: DEFAULT_TRANSFORM.rotate,
})}
/>
</div>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}

View File

@ -1,265 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
import { useEditor } from "../use-editor";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement | null>;
headerRef?: React.RefObject<HTMLElement | null>;
onSelectionComplete: ({
elements,
isAdditive,
}: {
elements: { trackId: string; elementId: string }[];
isAdditive: boolean;
}) => void;
isEnabled?: boolean;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
zoomLevel: number;
}
interface SelectionBoxState {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
isActive: boolean;
isAdditive: boolean;
}
interface SelectionRectangle {
left: number;
top: number;
right: number;
bottom: number;
}
function getNormalizedRectangle({
startPos,
endPos,
}: {
startPos: { x: number; y: number };
endPos: { x: number; y: number };
}): SelectionRectangle {
return {
left: Math.min(startPos.x, endPos.x),
top: Math.min(startPos.y, endPos.y),
right: Math.max(startPos.x, endPos.x),
bottom: Math.max(startPos.y, endPos.y),
};
}
function getSelectionRectangleInContent({
container,
scrollContainer,
startPos,
endPos,
}: {
container: HTMLElement;
scrollContainer: HTMLDivElement | null;
startPos: { x: number; y: number };
endPos: { x: number; y: number };
}): SelectionRectangle {
const containerRect = container.getBoundingClientRect();
const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
const scrollTop = scrollContainer?.scrollTop ?? 0;
const adjustedStart = {
x: startPos.x - containerRect.left + scrollLeft,
y: startPos.y - scrollRect.top + scrollTop,
};
const adjustedEnd = {
x: endPos.x - containerRect.left + scrollLeft,
y: endPos.y - scrollRect.top + scrollTop,
};
return getNormalizedRectangle({
startPos: adjustedStart,
endPos: adjustedEnd,
});
}
function isRectangleIntersecting({
elementRectangle,
selectionRectangle,
}: {
elementRectangle: SelectionRectangle;
selectionRectangle: SelectionRectangle;
}): boolean {
return !(
elementRectangle.right < selectionRectangle.left ||
elementRectangle.left > selectionRectangle.right ||
elementRectangle.bottom < selectionRectangle.top ||
elementRectangle.top > selectionRectangle.bottom
);
}
export function useSelectionBox({
containerRef,
headerRef,
onSelectionComplete,
isEnabled = true,
tracksScrollRef,
zoomLevel,
}: UseSelectionBoxProps) {
const editor = useEditor();
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
null,
);
const justFinishedSelectingRef = useRef(false);
const handleMouseDown = useCallback(
({ clientX, clientY, shiftKey, ctrlKey, metaKey }: React.MouseEvent) => {
if (!isEnabled) return;
setSelectionBox({
startPos: { x: clientX, y: clientY },
currentPos: { x: clientX, y: clientY },
isActive: false,
isAdditive: shiftKey || ctrlKey || metaKey,
});
},
[isEnabled],
);
const selectElementsInBox = useCallback(
({
startPos,
endPos,
isAdditive,
}: {
startPos: { x: number; y: number };
endPos: { x: number; y: number };
isAdditive: boolean;
}) => {
if (!containerRef.current) return;
const tracks = editor.timeline.getTracks();
const container = containerRef.current;
const selectionRectangle = getSelectionRectangleInContent({
container,
scrollContainer: tracksScrollRef.current,
startPos,
endPos,
});
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const timelineHeaderHeight =
headerRef?.current?.getBoundingClientRect().height ?? 0;
const selectedElements: { trackId: string; elementId: string }[] = [];
for (const [trackIndex, track] of tracks.entries()) {
const trackTop = getCumulativeHeightBefore({
tracks,
trackIndex,
});
const trackHeight = getTrackHeight({ type: track.type });
const elementTop =
timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
const elementBottom = elementTop + trackHeight;
for (const element of track.elements) {
const elementLeft = element.startTime * pixelsPerSecond;
const elementRight = elementLeft + element.duration * pixelsPerSecond;
const elementRectangle = {
left: elementLeft,
top: elementTop,
right: elementRight,
bottom: elementBottom,
};
const intersects = isRectangleIntersecting({
elementRectangle,
selectionRectangle,
});
if (intersects) {
selectedElements.push({
trackId: track.id,
elementId: element.id,
});
}
}
}
onSelectionComplete({ elements: selectedElements, isAdditive });
},
[
containerRef,
headerRef,
onSelectionComplete,
editor,
tracksScrollRef,
zoomLevel,
],
);
useEffect(() => {
if (!selectionBox) return;
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
const shouldActivate = deltaX > 5 || deltaY > 5;
const newSelectionBox = {
...selectionBox,
currentPos: { x: clientX, y: clientY },
isActive: shouldActivate || selectionBox.isActive,
};
setSelectionBox(newSelectionBox);
if (newSelectionBox.isActive) {
selectElementsInBox({
startPos: newSelectionBox.startPos,
endPos: newSelectionBox.currentPos,
isAdditive: newSelectionBox.isAdditive,
});
}
};
const handleMouseUp = () => {
if (selectionBox?.isActive) {
justFinishedSelectingRef.current = true;
requestAnimationFrame(() => {
justFinishedSelectingRef.current = false;
});
}
setSelectionBox(null);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [selectionBox, selectElementsInBox]);
useEffect(() => {
if (!selectionBox) return;
const previousBodyUserSelect = document.body.style.userSelect;
const container = containerRef.current;
const previousContainerUserSelect = container?.style.userSelect ?? "";
document.body.style.userSelect = "none";
if (container) container.style.userSelect = "none";
return () => {
document.body.style.userSelect = previousBodyUserSelect;
if (container) container.style.userSelect = previousContainerUserSelect;
};
}, [selectionBox, containerRef]);
const shouldIgnoreClick = useCallback(() => {
return justFinishedSelectingRef.current;
}, []);
return {
selectionBox,
handleMouseDown,
isSelecting: selectionBox?.isActive || false,
shouldIgnoreClick,
};
}

View File

@ -1,198 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface SelectionBoxState {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
isActive: boolean;
isAdditive: boolean;
initialSelectedIds: string[];
initialAnchorId: string | null;
}
function isIntersecting({
selectionRect,
itemRect,
}: {
selectionRect: DOMRect;
itemRect: DOMRect;
}) {
return !(
itemRect.right < selectionRect.left ||
itemRect.left > selectionRect.right ||
itemRect.bottom < selectionRect.top ||
itemRect.top > selectionRect.bottom
);
}
function createSelectionRect({
startPos,
currentPos,
}: {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
}) {
return new DOMRect(
Math.min(startPos.x, currentPos.x),
Math.min(startPos.y, currentPos.y),
Math.abs(currentPos.x - startPos.x),
Math.abs(currentPos.y - startPos.y),
);
}
export function useAssetsSelectionBox({
containerRef,
getSelectableElements,
selectedIds,
selectionAnchorId,
onSelectionChange,
}: {
containerRef: React.RefObject<HTMLElement | null>;
getSelectableElements: () => Map<string, HTMLElement>;
selectedIds: string[];
selectionAnchorId: string | null;
onSelectionChange: ({
intersectedIds,
initialSelectedIds,
initialAnchorId,
isAdditive,
}: {
intersectedIds: string[];
initialSelectedIds: string[];
initialAnchorId: string | null;
isAdditive: boolean;
}) => void;
}) {
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
null,
);
const justFinishedSelectingRef = useRef(false);
const isAssetItemTarget = useCallback((target: EventTarget | null) => {
return target instanceof Element && target.closest("[data-asset-item='true']");
}, []);
const handleMouseDown = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (event.button !== 0) {
return;
}
if (isAssetItemTarget(event.target)) {
return;
}
setSelectionBox({
startPos: { x: event.clientX, y: event.clientY },
currentPos: { x: event.clientX, y: event.clientY },
isActive: false,
isAdditive: event.ctrlKey || event.metaKey,
initialSelectedIds: selectedIds,
initialAnchorId: selectionAnchorId,
});
},
[isAssetItemTarget, selectedIds, selectionAnchorId],
);
const updateSelection = useCallback(
({
startPos,
currentPos,
isAdditive,
initialSelectedIds,
initialAnchorId,
}: SelectionBoxState) => {
const selectionRect = createSelectionRect({ startPos, currentPos });
const intersectedIds = [...getSelectableElements().entries()]
.filter(([, element]) =>
isIntersecting({
selectionRect,
itemRect: element.getBoundingClientRect(),
}),
)
.map(([id]) => id);
onSelectionChange({
intersectedIds,
initialSelectedIds,
initialAnchorId,
isAdditive,
});
},
[getSelectableElements, onSelectionChange],
);
useEffect(() => {
if (!selectionBox) {
return;
}
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
const shouldActivate = deltaX > 5 || deltaY > 5;
const nextSelectionBox = {
...selectionBox,
currentPos: { x: clientX, y: clientY },
isActive: shouldActivate || selectionBox.isActive,
};
setSelectionBox(nextSelectionBox);
if (!nextSelectionBox.isActive) {
return;
}
updateSelection(nextSelectionBox);
};
const handleMouseUp = () => {
if (selectionBox.isActive) {
justFinishedSelectingRef.current = true;
requestAnimationFrame(() => {
justFinishedSelectingRef.current = false;
});
}
setSelectionBox(null);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [selectionBox, updateSelection]);
useEffect(() => {
if (!selectionBox) {
return;
}
const previousBodyUserSelect = document.body.style.userSelect;
const previousContainerUserSelect = containerRef.current?.style.userSelect ?? "";
document.body.style.userSelect = "none";
if (containerRef.current) {
containerRef.current.style.userSelect = "none";
}
return () => {
document.body.style.userSelect = previousBodyUserSelect;
if (containerRef.current) {
containerRef.current.style.userSelect = previousContainerUserSelect;
}
};
}, [containerRef, selectionBox]);
const shouldIgnoreClick = useCallback(() => {
return justFinishedSelectingRef.current;
}, []);
return {
selectionBox,
isSelecting: selectionBox?.isActive ?? false,
handleMouseDown,
shouldIgnoreClick,
};
}

View File

@ -1,209 +0,0 @@
import { useCallback, useEffect, useMemo } from "react";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
function mergeUniqueIds({ ids }: { ids: string[] }) {
return [...new Set(ids)];
}
function getRangeIds({
orderedIds,
anchorId,
targetId,
}: {
orderedIds: string[];
anchorId: string;
targetId: string;
}) {
const anchorIndex = orderedIds.indexOf(anchorId);
const targetIndex = orderedIds.indexOf(targetId);
if (anchorIndex === -1 || targetIndex === -1) {
return [targetId];
}
const rangeStart = Math.min(anchorIndex, targetIndex);
const rangeEnd = Math.max(anchorIndex, targetIndex);
return orderedIds.slice(rangeStart, rangeEnd + 1);
}
export function useAssetsSelection({
orderedIds,
}: {
orderedIds: string[];
}) {
const {
selectedMediaIds,
selectionAnchorId,
setMediaSelection,
clearMediaSelection,
} = useAssetsPanelStore();
const selectedIdSet = useMemo(() => {
return new Set(selectedMediaIds);
}, [selectedMediaIds]);
useEffect(() => {
const validIds = new Set(orderedIds);
const nextSelectedIds = selectedMediaIds.filter((id) => validIds.has(id));
const nextAnchorId =
selectionAnchorId && validIds.has(selectionAnchorId)
? selectionAnchorId
: nextSelectedIds[nextSelectedIds.length - 1] ?? null;
const isSelectionUnchanged =
nextSelectedIds.length === selectedMediaIds.length &&
nextAnchorId === selectionAnchorId;
if (isSelectionUnchanged) {
return;
}
setMediaSelection({
ids: nextSelectedIds,
anchorId: nextAnchorId,
});
}, [orderedIds, selectedMediaIds, selectionAnchorId, setMediaSelection]);
const replaceSelection = useCallback(
(ids: string[], anchorId?: string | null) => {
setMediaSelection({
ids: mergeUniqueIds({ ids }),
anchorId,
});
},
[setMediaSelection],
);
const isSelected = useCallback(
(id: string) => {
return selectedIdSet.has(id);
},
[selectedIdSet],
);
const toggleSelection = useCallback(
(id: string) => {
if (selectedIdSet.has(id)) {
const nextSelectedIds = selectedMediaIds.filter(
(selectedId) => selectedId !== id,
);
const nextAnchorId =
selectionAnchorId === id
? nextSelectedIds[nextSelectedIds.length - 1] ?? null
: selectionAnchorId;
replaceSelection(nextSelectedIds, nextAnchorId);
return;
}
replaceSelection([...selectedMediaIds, id], id);
},
[
selectedIdSet,
selectedMediaIds,
selectionAnchorId,
replaceSelection,
],
);
const selectRange = useCallback(
({
targetId,
isAdditive,
}: {
targetId: string;
isAdditive: boolean;
}) => {
const anchorId =
selectionAnchorId ??
selectedMediaIds[selectedMediaIds.length - 1] ??
targetId;
const rangeIds = getRangeIds({
orderedIds,
anchorId,
targetId,
});
const nextSelectedIds = isAdditive
? mergeUniqueIds({
ids: [...selectedMediaIds, ...rangeIds],
})
: rangeIds;
replaceSelection(nextSelectedIds, anchorId);
},
[orderedIds, replaceSelection, selectedMediaIds, selectionAnchorId],
);
const handleItemClick = useCallback(
({
event,
id,
}: {
event: React.MouseEvent;
id: string;
}) => {
const isToggleSelection = event.ctrlKey || event.metaKey;
if (event.shiftKey) {
selectRange({
targetId: id,
isAdditive: isToggleSelection,
});
return;
}
if (isToggleSelection) {
toggleSelection(id);
return;
}
replaceSelection([id], id);
},
[replaceSelection, selectRange, toggleSelection],
);
const handleItemContextMenu = useCallback(
(id: string) => {
if (selectedIdSet.has(id)) {
return;
}
replaceSelection([id], id);
},
[selectedIdSet, replaceSelection],
);
const applyBoxSelection = useCallback(
({
intersectedIds,
initialSelectedIds,
initialAnchorId,
isAdditive,
}: {
intersectedIds: string[];
initialSelectedIds: string[];
initialAnchorId: string | null;
isAdditive: boolean;
}) => {
const nextSelectedIds = isAdditive
? mergeUniqueIds({
ids: [...initialSelectedIds, ...intersectedIds],
})
: intersectedIds;
const nextAnchorId = isAdditive
? initialAnchorId ?? intersectedIds[intersectedIds.length - 1] ?? null
: intersectedIds[intersectedIds.length - 1] ?? null;
replaceSelection(nextSelectedIds, nextAnchorId);
},
[replaceSelection],
);
return {
selectedMediaIds,
selectionAnchorId,
isSelected,
replaceSelection,
clearSelection: clearMediaSelection,
handleItemClick,
handleItemContextMenu,
applyBoxSelection,
};
}

View File

@ -1,31 +0,0 @@
import { useCallback, useRef } from "react";
export function useElementRegistry() {
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
const registerElement = useCallback(
(id: string, element: HTMLElement | null) => {
if (element) {
elementRefs.current.set(id, element);
return;
}
elementRefs.current.delete(id);
},
[],
);
const getElement = useCallback((id: string) => {
return elementRefs.current.get(id) ?? null;
}, []);
const getElements = useCallback(() => {
return elementRefs.current;
}, []);
return {
registerElement,
getElement,
getElements,
};
}

View File

@ -1,28 +0,0 @@
import { useEffect, useState } from "react";
export function useRevealItem(
highlightId: string | null,
onClearHighlight: () => void,
getElement: (id: string) => HTMLElement | null,
highlightDuration = 1000,
) {
const [highlightedId, setHighlightedId] = useState<string | null>(null);
useEffect(() => {
if (!highlightId) return;
setHighlightedId(highlightId);
const target = getElement(highlightId);
target?.scrollIntoView({ block: "center" });
const timeout = setTimeout(() => {
setHighlightedId(null);
onClearHighlight();
}, highlightDuration);
return () => clearTimeout(timeout);
}, [highlightId, onClearHighlight, highlightDuration, getElement]);
return { highlightedId };
}