mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
chore: normalize line endings
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user