summaryrefslogtreecommitdiff
path: root/legacy
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-27 00:55:15 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:56:48 +0200
commit4d127a038bf768eded68aae3cfa5c1b12241bdac (patch)
tree16bbb801caf6770546cff4fdb37b6408ec9ead98 /legacy
parent82817cb50cb0487350573d6129130aec705f924b (diff)
downloadopencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz
chore: normalize line endings
Diffstat (limited to 'legacy')
-rw-r--r--legacy/components/properties/audio-properties.tsx6
-rw-r--r--legacy/components/properties/clip-effects-properties.tsx430
-rw-r--r--legacy/components/properties/effect-param-field.tsx304
-rw-r--r--legacy/components/properties/effect-properties.tsx116
-rw-r--r--legacy/components/properties/keyframe-toggle.tsx64
-rw-r--r--legacy/components/properties/section.tsx524
-rw-r--r--legacy/components/properties/text-properties.tsx1464
-rw-r--r--legacy/components/properties/video-properties.tsx50
-rw-r--r--legacy/sections/blending.tsx444
-rw-r--r--legacy/sections/index.tsx4
-rw-r--r--legacy/sections/transform.tsx764
-rw-r--r--legacy/timeline/use-selection-box.ts530
-rw-r--r--legacy/use-assets-selection-box.ts396
-rw-r--r--legacy/use-assets-selection.ts418
-rw-r--r--legacy/use-element-registry.ts62
-rw-r--r--legacy/use-reveal-item.ts56
16 files changed, 2816 insertions, 2816 deletions
diff --git a/legacy/components/properties/audio-properties.tsx b/legacy/components/properties/audio-properties.tsx
index 2ad46497..9e2b0b9a 100644
--- a/legacy/components/properties/audio-properties.tsx
+++ b/legacy/components/properties/audio-properties.tsx
@@ -1,3 +1,3 @@
-export function AudioProperties() {
- return <div className="space-y-4 p-5">Audio properties</div>;
-}
+export function AudioProperties() {
+ return <div className="space-y-4 p-5">Audio properties</div>;
+}
diff --git a/legacy/components/properties/clip-effects-properties.tsx b/legacy/components/properties/clip-effects-properties.tsx
index a1324bdf..e409c22d 100644
--- a/legacy/components/properties/clip-effects-properties.tsx
+++ b/legacy/components/properties/clip-effects-properties.tsx
@@ -1,215 +1,215 @@
-"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>
- );
-}
+"use client";
+
+import { useEffect, useState } from "react";
+import type { Effect } from "@/types/effects";
+import type { VisualElement } from "@/types/timeline";
+import { getEffect } from "@/lib/effects/registry";
+import { useEditor } from "@/hooks/use-editor";
+import { usePropertiesStore } from "@/stores/properties-store";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "./section";
+import { EffectParamField } from "./effect-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowLeft01Icon,
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+} from "@hugeicons/core-free-icons";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/utils/ui";
+export function ClipEffectsProperties({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const closeClipEffects = usePropertiesStore(
+ (state) => state.closeClipEffects,
+ );
+ const editor = useEditor();
+ const effects = element.effects ?? [];
+
+ useEffect(() => {
+ if (effects.length === 0) closeClipEffects();
+ }, [effects.length, closeClipEffects]);
+
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+
+ const handleDragStart = ({ index }: { index: number }) => {
+ setDragIndex(index);
+ };
+
+ const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+ <div className="flex h-full flex-col">
+ <div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5">
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={closeClipEffects}
+ aria-label="Back to properties"
+ >
+ <HugeiconsIcon icon={ArrowLeft01Icon} />
+ </Button>
+ <span className="text-sm font-medium">Effects</span>
+ </div>
+ <ScrollArea className="flex-1 scrollbar-hidden">
+ {effects.map((effect, index) => (
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder
+ <div
+ key={effect.id}
+ draggable
+ onDragStart={() => handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group",
+ dragIndex === index && "opacity-40",
+ dropIndex === index &&
+ dragIndex !== null &&
+ dragIndex !== index &&
+ (index < dragIndex
+ ? "border-t-2 border-primary"
+ : "border-b-2 border-primary"),
+ )}
+ >
+ <ClipEffectSection
+ effect={effect}
+ element={element}
+ trackId={trackId}
+ />
+ </div>
+ ))}
+ </ScrollArea>
+ </div>
+ );
+}
+
+function ClipEffectSection({
+ effect,
+ element,
+ trackId,
+}: {
+ effect: Effect;
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = getEffect({ effectType: effect.type });
+
+ const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => {
+ const updatedEffects = (element.effects ?? []).map((existing) =>
+ existing.id !== effect.id
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { effects: updatedEffects },
+ },
+ ],
+ });
+ };
+
+ const commitParam = () => editor.timeline.commitPreview();
+
+ const toggleEffect = () =>
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ });
+
+ const removeEffect = () =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ });
+
+ return (
+ <Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}>
+ <SectionHeader
+ className="cursor-move"
+ trailing={
+ <div className="flex items-center gap-1">
+ <Button
+ variant={effect.enabled ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={`Toggle ${definition.name}`}
+ onClick={toggleEffect}
+ >
+ <HugeiconsIcon
+ icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name}`}
+ onClick={removeEffect}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ }
+ >
+ <SectionTitle
+ className={cn(!effect.enabled && "text-muted-foreground")}
+ >
+ {definition.name}
+ </SectionTitle>
+ </SectionHeader>
+ {effect.enabled && (
+ <SectionContent>
+ <SectionFields>
+ {definition.params.map((param) => (
+ <EffectParamField
+ key={param.key}
+ param={param}
+ value={effect.params[param.key] ?? param.default}
+ onPreview={previewParam({ key: param.key })}
+ onCommit={commitParam}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ )}
+ </Section>
+ );
+}
diff --git a/legacy/components/properties/effect-param-field.tsx b/legacy/components/properties/effect-param-field.tsx
index 408d430a..3aa87296 100644
--- a/legacy/components/properties/effect-param-field.tsx
+++ b/legacy/components/properties/effect-param-field.tsx
@@ -1,152 +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>
- );
-}
+"use client";
+
+import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects";
+import { clamp } from "@/utils/math";
+import { SectionField } from "./section";
+import { Slider } from "@/components/ui/slider";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { usePropertyDraft } from "./hooks/use-property-draft";
+
+export function EffectParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: EffectParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ return (
+ <SectionField label={param.label}>
+ <EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} />
+ </SectionField>
+ );
+}
+
+function EffectParamInput({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: EffectParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ if (param.type === "number") {
+ return (
+ <NumberParamField
+ param={param}
+ value={typeof value === "number" ? value : Number(value)}
+ onPreview={onPreview}
+ onCommit={onCommit}
+ />
+ );
+ }
+
+ if (param.type === "boolean") {
+ return (
+ <Switch
+ checked={Boolean(value)}
+ onCheckedChange={(checked) => {
+ onPreview(checked);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ if (param.type === "select") {
+ return (
+ <Select
+ value={String(value)}
+ onValueChange={(selected) => {
+ onPreview(selected);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {param.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+ }
+
+ if (param.type === "color") {
+ return (
+ <input
+ type="color"
+ className="h-8 w-full cursor-pointer rounded border"
+ value={String(value)}
+ onChange={(event) => onPreview(event.target.value)}
+ onBlur={onCommit}
+ />
+ );
+ }
+
+ return null;
+}
+
+function NumberParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: NumberEffectParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+}) {
+ const { min, max, step } = param;
+
+ const draft = usePropertyDraft({
+ displayValue: String(value),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min, max });
+ },
+ onPreview,
+ onCommit,
+ });
+
+ return (
+ <div className="flex items-center gap-3">
+ <Slider
+ className="flex-1"
+ min={min}
+ max={max}
+ step={step}
+ value={[value]}
+ onValueChange={([newValue]) => onPreview(newValue)}
+ onValueCommit={onCommit}
+ />
+ <NumberField
+ className="w-16 shrink-0"
+ value={draft.displayValue}
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ />
+ </div>
+ );
+}
diff --git a/legacy/components/properties/effect-properties.tsx b/legacy/components/properties/effect-properties.tsx
index 75fc30a1..78582961 100644
--- a/legacy/components/properties/effect-properties.tsx
+++ b/legacy/components/properties/effect-properties.tsx
@@ -1,58 +1,58 @@
-"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>
- );
-}
+"use client";
+
+import type { EffectElement } from "@/types/timeline";
+import { getEffect } from "@/lib/effects/registry";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionFields,
+ SectionTitle,
+} from "./section";
+import { EffectParamField } from "./effect-param-field";
+
+export function EffectProperties({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = getEffect({ effectType: element.effectType });
+
+ const previewParam =
+ ({ key }: { key: string }) =>
+ (value: number | string | boolean) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, [key]: value } },
+ },
+ ],
+ });
+
+ return (
+ <Section showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {definition.params.map((param) => (
+ <EffectParamField
+ key={param.key}
+ param={param}
+ value={element.params[param.key] ?? param.default}
+ onPreview={previewParam({ key: param.key })}
+ onCommit={() => editor.timeline.commitPreview()}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/legacy/components/properties/keyframe-toggle.tsx b/legacy/components/properties/keyframe-toggle.tsx
index b5b8685f..00cea82b 100644
--- a/legacy/components/properties/keyframe-toggle.tsx
+++ b/legacy/components/properties/keyframe-toggle.tsx
@@ -1,32 +1,32 @@
-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>
- );
-}
+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>
+ );
+}
diff --git a/legacy/components/properties/section.tsx b/legacy/components/properties/section.tsx
index 5989108b..07d1c0ca 100644
--- a/legacy/components/properties/section.tsx
+++ b/legacy/components/properties/section.tsx
@@ -1,262 +1,262 @@
-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>;
-}
+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>;
+}
diff --git a/legacy/components/properties/text-properties.tsx b/legacy/components/properties/text-properties.tsx
index ae6de420..7af95450 100644
--- a/legacy/components/properties/text-properties.tsx
+++ b/legacy/components/properties/text-properties.tsx
@@ -1,732 +1,732 @@
-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>
- );
-}
+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>
+ );
+}
diff --git a/legacy/components/properties/video-properties.tsx b/legacy/components/properties/video-properties.tsx
index 49dd8fe5..b12203d1 100644
--- a/legacy/components/properties/video-properties.tsx
+++ b/legacy/components/properties/video-properties.tsx
@@ -1,25 +1,25 @@
-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>
- );
-}
+import type {
+ ImageElement,
+ StickerElement,
+ VideoElement,
+} from "@/types/timeline";
+import { BlendingSection, TransformSection } from "./sections";
+
+export function VideoProperties({
+ element,
+ trackId,
+}: {
+ element: VideoElement | ImageElement | StickerElement;
+ trackId: string;
+}) {
+ return (
+ <div className="flex h-full flex-col">
+ <TransformSection
+ element={element}
+ trackId={trackId}
+ showTopBorder={false}
+ />
+ <BlendingSection element={element} trackId={trackId} />
+ </div>
+ );
+}
diff --git a/legacy/sections/blending.tsx b/legacy/sections/blending.tsx
index a50e84e7..a8297b86 100644
--- a/legacy/sections/blending.tsx
+++ b/legacy/sections/blending.tsx
@@ -1,222 +1,222 @@
-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>
- );
-}
+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>
+ );
+}
diff --git a/legacy/sections/index.tsx b/legacy/sections/index.tsx
index 8be58f39..d1c87a9a 100644
--- a/legacy/sections/index.tsx
+++ b/legacy/sections/index.tsx
@@ -1,2 +1,2 @@
-export * from "./transform";
-export * from "./blending";
+export * from "./transform";
+export * from "./blending";
diff --git a/legacy/sections/transform.tsx b/legacy/sections/transform.tsx
index 6aaaa1fe..f9d68065 100644
--- a/legacy/sections/transform.tsx
+++ b/legacy/sections/transform.tsx
@@ -1,382 +1,382 @@
-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>
- );
-}
+import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { AnimationPropertyPath } from "@/types/animation";
+import type { VisualElement } from "@/types/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "../section";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Link05Icon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import { useState } from "react";
+import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
+import { KeyframeToggle } from "../keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+
+export function parseNumericInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function isPropertyAtDefault({
+ hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ staticValue,
+ defaultValue,
+}: {
+ hasAnimatedKeyframes: boolean;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number;
+ staticValue: number;
+ defaultValue: number;
+}): boolean {
+ if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
+ return isNearlyEqual({
+ leftValue: resolvedValue,
+ rightValue: defaultValue,
+ });
+ }
+
+ return staticValue === defaultValue;
+}
+
+export function TransformSection({
+ element,
+ trackId,
+ showTopBorder = true,
+}: {
+ element: VisualElement;
+ trackId: string;
+ showTopBorder?: boolean;
+}) {
+ const editor = useEditor();
+ const [isScaleLocked, setIsScaleLocked] = useState(false);
+ const playheadTime = editor.playback.getCurrentTime();
+ const localTime = getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ const isPlayheadWithinElementRange =
+ playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
+ playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
+
+ const positionX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position.x",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.position.x).toString(),
+ parse: (input) => parseNumericInput({ input }),
+ valueAtPlayhead: resolvedTransform.position.x,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ x: value,
+ },
+ },
+ }),
+ });
+
+ const positionY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position.y",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.position.y).toString(),
+ parse: (input) => parseNumericInput({ input }),
+ valueAtPlayhead: resolvedTransform.position.y,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ y: value,
+ },
+ },
+ }),
+ });
+
+ const scale = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scale",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scale * 100).toString(),
+ parse: (input) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return Math.max(parsed, 1) / 100;
+ },
+ valueAtPlayhead: resolvedTransform.scale,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scale: value,
+ },
+ }),
+ });
+ const scaleFieldProps = {
+ className: "flex-1",
+ value: scale.displayValue,
+ onFocus: scale.onFocus,
+ onChange: scale.onChange,
+ onBlur: scale.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scale.scrubTo,
+ onScrubEnd: scale.commitScrub,
+ onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scale,
+ staticValue: element.transform.scale,
+ defaultValue: DEFAULT_TRANSFORM.scale,
+ }),
+ };
+
+ const rotation = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.rotate",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.rotate).toString(),
+ parse: (input) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ valueAtPlayhead: resolvedTransform.rotate,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
+ });
+
+ const hasPositionKeyframe =
+ positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
+
+ const togglePositionKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
+ const keyframesToRemove: Array<{
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ }> = [];
+ if (positionX.keyframeIdAtTime) {
+ keyframesToRemove.push({
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.x" as const,
+ keyframeId: positionX.keyframeIdAtTime,
+ });
+ }
+ if (positionY.keyframeIdAtTime) {
+ keyframesToRemove.push({
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.y" as const,
+ keyframeId: positionY.keyframeIdAtTime,
+ });
+ }
+
+ editor.timeline.removeKeyframes({
+ keyframes: keyframesToRemove,
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.x",
+ time: localTime,
+ value: resolvedTransform.position.x,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.y",
+ time: localTime,
+ value: resolvedTransform.position.y,
+ },
+ ],
+ });
+ };
+
+ return (
+ <Section
+ collapsible
+ sectionKey={`${element.type}:transform`}
+ showTopBorder={showTopBorder}
+ >
+ <SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Scale"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scale.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle scale keyframe"
+ onToggle={scale.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ {isScaleLocked ? (
+ <>
+ <NumberField icon="W" {...scaleFieldProps} />
+ <NumberField icon="H" {...scaleFieldProps} />
+ </>
+ ) : (
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldProps}
+ className="flex-1"
+ />
+ )}
+ <Button
+ type="button"
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ </div>
+ </SectionField>
+ <SectionField
+ label="Position"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={hasPositionKeyframe}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle position keyframe"
+ onToggle={togglePositionKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon="X"
+ className="flex-1"
+ value={positionX.displayValue}
+ onFocus={positionX.onFocus}
+ onChange={positionX.onChange}
+ onBlur={positionX.onBlur}
+ onScrub={positionX.scrubTo}
+ onScrubEnd={positionX.commitScrub}
+ onReset={() =>
+ positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULT_TRANSFORM.position.x,
+ })}
+ />
+ <NumberField
+ icon="Y"
+ className="flex-1"
+ value={positionY.displayValue}
+ onFocus={positionY.onFocus}
+ onChange={positionY.onChange}
+ onBlur={positionY.onBlur}
+ onScrub={positionY.scrubTo}
+ onScrubEnd={positionY.commitScrub}
+ onReset={() =>
+ positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULT_TRANSFORM.position.y,
+ })}
+ />
+ </div>
+ </SectionField>
+
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ onToggle={rotation.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ className="flex-none"
+ value={rotation.displayValue}
+ onFocus={rotation.onFocus}
+ onChange={rotation.onChange}
+ onBlur={rotation.onBlur}
+ dragSensitivity="slow"
+ onScrub={rotation.scrubTo}
+ onScrubEnd={rotation.commitScrub}
+ onReset={() =>
+ rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.rotate,
+ staticValue: element.transform.rotate,
+ defaultValue: DEFAULT_TRANSFORM.rotate,
+ })}
+ />
+ </div>
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/legacy/timeline/use-selection-box.ts b/legacy/timeline/use-selection-box.ts
index 027d3d76..979bb480 100644
--- a/legacy/timeline/use-selection-box.ts
+++ b/legacy/timeline/use-selection-box.ts
@@ -1,265 +1,265 @@
-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,
- };
-}
+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,
+ };
+}
diff --git a/legacy/use-assets-selection-box.ts b/legacy/use-assets-selection-box.ts
index cb8ae2ed..e0cf88c8 100644
--- a/legacy/use-assets-selection-box.ts
+++ b/legacy/use-assets-selection-box.ts
@@ -1,198 +1,198 @@
-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,
- };
-}
+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,
+ };
+}
diff --git a/legacy/use-assets-selection.ts b/legacy/use-assets-selection.ts
index 89d245e6..2b11a634 100644
--- a/legacy/use-assets-selection.ts
+++ b/legacy/use-assets-selection.ts
@@ -1,209 +1,209 @@
-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,
- };
-}
+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,
+ };
+}
diff --git a/legacy/use-element-registry.ts b/legacy/use-element-registry.ts
index 85704318..df3315cb 100644
--- a/legacy/use-element-registry.ts
+++ b/legacy/use-element-registry.ts
@@ -1,31 +1,31 @@
-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,
- };
-}
+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,
+ };
+}
diff --git a/legacy/use-reveal-item.ts b/legacy/use-reveal-item.ts
index f77ef17f..dd13d844 100644
--- a/legacy/use-reveal-item.ts
+++ b/legacy/use-reveal-item.ts
@@ -1,28 +1,28 @@
-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 };
-}
+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 };
+}