summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/properties')
-rw-r--r--apps/web/src/components/editor/panels/properties/audio-properties.tsx4
-rw-r--r--apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx211
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-param-field.tsx152
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-properties.tsx103
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx28
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx154
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx8
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx25
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx467
-rw-r--r--apps/web/src/components/editor/panels/properties/video-properties.tsx12
10 files changed, 832 insertions, 332 deletions
diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
index 0b83a4b6..2ad46497 100644
--- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
@@ -1,5 +1,3 @@
-import type { AudioElement } from "@/types/timeline";
-
-export function AudioProperties({ _element }: { _element: AudioElement }) {
+export function AudioProperties() {
return <div className="space-y-4 p-5">Audio properties</div>;
}
diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
new file mode 100644
index 00000000..1caf73eb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { useState } from "react";
+import type { Effect } from "@/types/effects";
+import type { VisualElement } from "@/types/timeline";
+import { getEffect } from "@/lib/effects/registry";
+import { useEditor } from "@/hooks/use-editor";
+import { usePropertiesStore } from "@/stores/properties-store";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "./section";
+import { EffectParamField } from "./effect-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowLeft01Icon,
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+} from "@hugeicons/core-free-icons";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/utils/ui";
+export function ClipEffectsProperties({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const closeClipEffects = usePropertiesStore(
+ (state) => state.closeClipEffects,
+ );
+ const editor = useEditor();
+ const effects = element.effects ?? [];
+
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+
+ const handleDragStart = ({ index }: { index: number }) => {
+ setDragIndex(index);
+ };
+
+ const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+ <div className="flex h-full flex-col">
+ <div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5">
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={closeClipEffects}
+ aria-label="Back to properties"
+ >
+ <HugeiconsIcon icon={ArrowLeft01Icon} />
+ </Button>
+ <span className="text-sm font-medium">Effects</span>
+ </div>
+ <ScrollArea className="flex-1 scrollbar-hidden">
+ {effects.map((effect, index) => (
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder
+ <div
+ key={effect.id}
+ draggable
+ onDragStart={() => handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group",
+ dragIndex === index && "opacity-40",
+ dropIndex === index &&
+ dragIndex !== null &&
+ dragIndex !== index &&
+ (index < dragIndex
+ ? "border-t-2 border-primary"
+ : "border-b-2 border-primary"),
+ )}
+ >
+ <ClipEffectSection
+ effect={effect}
+ element={element}
+ trackId={trackId}
+ />
+ </div>
+ ))}
+ </ScrollArea>
+ </div>
+ );
+}
+
+function ClipEffectSection({
+ effect,
+ element,
+ trackId,
+}: {
+ effect: Effect;
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = getEffect({ effectType: effect.type });
+
+ const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => {
+ const updatedEffects = (element.effects ?? []).map((existing) =>
+ existing.id !== effect.id
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { effects: updatedEffects },
+ },
+ ],
+ });
+ };
+
+ const commitParam = () => editor.timeline.commitPreview();
+
+ const toggleEffect = () =>
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ });
+
+ const removeEffect = () =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ });
+
+ return (
+ <Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}>
+ <SectionHeader
+ className="cursor-move"
+ trailing={
+ <>
+ <Button
+ variant={effect.enabled ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={`Toggle ${definition.name}`}
+ onClick={toggleEffect}
+ >
+ <HugeiconsIcon
+ icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name}`}
+ onClick={removeEffect}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </>
+ }
+ >
+ <SectionTitle
+ className={cn(!effect.enabled && "text-muted-foreground")}
+ >
+ {definition.name}
+ </SectionTitle>
+ </SectionHeader>
+ {effect.enabled && (
+ <SectionContent>
+ <SectionFields>
+ {definition.params.map((param) => (
+ <EffectParamField
+ key={param.key}
+ param={param}
+ value={effect.params[param.key] ?? param.default}
+ onPreview={previewParam({ key: param.key })}
+ onCommit={commitParam}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ )}
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
new file mode 100644
index 00000000..408d430a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects";
+import { clamp } from "@/utils/math";
+import { SectionField } from "./section";
+import { Slider } from "@/components/ui/slider";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { usePropertyDraft } from "./hooks/use-property-draft";
+
+export function EffectParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: EffectParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ return (
+ <SectionField label={param.label}>
+ <EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} />
+ </SectionField>
+ );
+}
+
+function EffectParamInput({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: EffectParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ if (param.type === "number") {
+ return (
+ <NumberParamField
+ param={param}
+ value={typeof value === "number" ? value : Number(value)}
+ onPreview={onPreview}
+ onCommit={onCommit}
+ />
+ );
+ }
+
+ if (param.type === "boolean") {
+ return (
+ <Switch
+ checked={Boolean(value)}
+ onCheckedChange={(checked) => {
+ onPreview(checked);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ if (param.type === "select") {
+ return (
+ <Select
+ value={String(value)}
+ onValueChange={(selected) => {
+ onPreview(selected);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {param.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+ }
+
+ if (param.type === "color") {
+ return (
+ <input
+ type="color"
+ className="h-8 w-full cursor-pointer rounded border"
+ value={String(value)}
+ onChange={(event) => onPreview(event.target.value)}
+ onBlur={onCommit}
+ />
+ );
+ }
+
+ return null;
+}
+
+function NumberParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: NumberEffectParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+}) {
+ const { min, max, step } = param;
+
+ const draft = usePropertyDraft({
+ displayValue: String(value),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min, max });
+ },
+ onPreview,
+ onCommit,
+ });
+
+ return (
+ <div className="flex items-center gap-3">
+ <Slider
+ className="flex-1"
+ min={min}
+ max={max}
+ step={step}
+ value={[value]}
+ onValueChange={([newValue]) => onPreview(newValue)}
+ onValueCommit={onCommit}
+ />
+ <NumberField
+ className="w-16 shrink-0"
+ value={draft.displayValue}
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
index bf137291..75fc30a1 100644
--- a/apps/web/src/components/editor/panels/properties/effect-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
@@ -1,78 +1,16 @@
"use client";
import type { EffectElement } from "@/types/timeline";
-import type { EffectParamDefinition } from "@/types/effects";
import { getEffect } from "@/lib/effects/registry";
import { useEditor } from "@/hooks/use-editor";
-import { clamp } from "@/utils/math";
-import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section";
-import { Slider } from "@/components/ui/slider";
-import { NumberField } from "@/components/ui/number-field";
-import { usePropertyDraft } from "./hooks/use-property-draft";
-
-function EffectParamField({
- param,
- element,
- trackId,
-}: {
- param: EffectParamDefinition;
- element: EffectElement;
- trackId: string;
-}) {
- const editor = useEditor();
-
- const currentValue = Number(element.params[param.key] ?? param.default);
- const min = param.min ?? 0;
- const max = param.max ?? 100;
- const step = param.step ?? 1;
-
- const updateParam = (value: number) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { params: { ...element.params, [param.key]: value } },
- },
- ],
- });
-
- const commitParam = () => editor.timeline.commitPreview();
-
- const draft = usePropertyDraft({
- displayValue: String(currentValue),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min, max });
- },
- onPreview: updateParam,
- onCommit: commitParam,
- });
-
- return (
- <SectionField label={param.label}>
- <div className="flex items-center gap-3">
- <Slider
- className="flex-1"
- min={min}
- max={max}
- step={step}
- value={[currentValue]}
- onValueChange={([value]) => updateParam(value)}
- onValueCommit={commitParam}
- />
- <NumberField
- className="w-16 shrink-0"
- value={draft.displayValue}
- onFocus={draft.onFocus}
- onChange={draft.onChange}
- onBlur={draft.onBlur}
- />
- </div>
- </SectionField>
- );
-}
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionFields,
+ SectionTitle,
+} from "./section";
+import { EffectParamField } from "./effect-param-field";
export function EffectProperties({
element,
@@ -81,19 +19,36 @@ export function EffectProperties({
element: EffectElement;
trackId: string;
}) {
+ const editor = useEditor();
const definition = getEffect({ effectType: element.effectType });
+ const previewParam =
+ ({ key }: { key: string }) =>
+ (value: number | string | boolean) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, [key]: value } },
+ },
+ ],
+ });
+
return (
- <Section hasBorderTop={false}>
- <SectionHeader title={definition.name} />
+ <Section showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
<SectionContent>
<SectionFields>
{definition.params.map((param) => (
<EffectParamField
key={param.key}
param={param}
- element={element}
- trackId={trackId}
+ value={element.params[param.key] ?? param.default}
+ onPreview={previewParam({ key: param.key })}
+ onCommit={() => editor.timeline.commitPreview()}
/>
))}
</SectionFields>
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index 75dd81a9..cb45996d 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -5,9 +5,12 @@ import { AudioProperties } from "./audio-properties";
import { VideoProperties } from "./video-properties";
import { TextProperties } from "./text-properties";
import { EffectProperties } from "./effect-properties";
+import { ClipEffectsProperties } from "./clip-effects-properties";
import { EmptyView } from "./empty-view";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { usePropertiesStore } from "@/stores/properties-store";
+import { isVisualElement } from "@/lib/timeline";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
function ElementProperties({
@@ -21,7 +24,7 @@ function ElementProperties({
return <TextProperties element={element} trackId={track.id} />;
}
if (element.type === "audio") {
- return <AudioProperties _element={element} />;
+ return <AudioProperties />;
}
if (
element.type === "video" ||
@@ -39,16 +42,33 @@ function ElementProperties({
export function PropertiesPanel() {
const editor = useEditor();
const { selectedElements } = useElementSelection();
+ const clipEffectsTarget = usePropertiesStore(
+ (state) => state.clipEffectsTarget,
+ );
+
+ const clipEffectsTrack = clipEffectsTarget
+ ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId })
+ : null;
+ const clipEffectsElement = clipEffectsTrack?.elements.find(
+ (element) => element.id === clipEffectsTarget?.elementId,
+ );
+ const isShowingClipEffects =
+ clipEffectsTrack &&
+ clipEffectsElement &&
+ isVisualElement(clipEffectsElement);
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: selectedElements,
});
- const hasSelection = selectedElements.length > 0;
-
return (
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
- {hasSelection ? (
+ {isShowingClipEffects ? (
+ <ClipEffectsProperties
+ element={clipEffectsElement}
+ trackId={clipEffectsTrack.id}
+ />
+ ) : selectedElements.length > 0 ? (
<ScrollArea className="h-full scrollbar-hidden">
{elementsWithTracks.map(({ track, element }) => (
<ElementProperties
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx
index 3edd17b5..4428b59c 100644
--- a/apps/web/src/components/editor/panels/properties/section.tsx
+++ b/apps/web/src/components/editor/panels/properties/section.tsx
@@ -1,10 +1,12 @@
-import { createContext, useContext, useState } from "react";
+import { createContext, useContext, useEffect, useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
const sectionExpandedCache = new Map<string, boolean>();
+const mountedSectionKeys = new Set<string>();
interface SectionContext {
isOpen: boolean;
@@ -24,8 +26,8 @@ interface SectionProps {
defaultOpen?: boolean;
sectionKey?: string;
className?: string;
- hasBorderTop?: boolean;
- hasBorderBottom?: boolean;
+ showTopBorder?: boolean;
+ showBottomBorder?: boolean;
}
export function Section({
@@ -34,12 +36,21 @@ export function Section({
defaultOpen = true,
sectionKey,
className,
- hasBorderTop = true,
- hasBorderBottom = true,
+ showTopBorder = true,
+ showBottomBorder = true,
}: SectionProps) {
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
+ useEffect(() => {
+ if (!sectionKey) return;
+ if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) {
+ console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`);
+ }
+ mountedSectionKeys.add(sectionKey);
+ return () => { mountedSectionKeys.delete(sectionKey); };
+ }, [sectionKey]);
+
const toggle = () => {
const next = !isOpen;
setIsOpen(next);
@@ -51,8 +62,8 @@ export function Section({
<div
className={cn(
"flex flex-col",
- hasBorderTop && "border-t",
- hasBorderBottom && "last:border-b",
+ showTopBorder && "border-t",
+ showBottomBorder && "last:border-b",
className,
)}
>
@@ -63,15 +74,19 @@ export function Section({
}
interface SectionHeaderProps {
- title: string;
children?: React.ReactNode;
+ trailing?: React.ReactNode;
+ leading?: React.ReactNode;
+ actions?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function SectionHeader({
- title,
children,
+ trailing,
+ leading,
+ actions,
onClick,
className,
}: SectionHeaderProps) {
@@ -79,57 +94,108 @@ export function SectionHeader({
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
const isInteractive = isCollapsible || !!onClick;
-
const handleClick = isCollapsible ? ctx?.toggle : onClick;
- const content = (
+ const chevronIcon = isCollapsible ? (
+ <HugeiconsIcon
+ icon={ArrowDownIcon}
+ className={cn(
+ "size-4 shrink-0 transition-transform duration-200 ease-out",
+ isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground",
+ )}
+ />
+ ) : null;
+
+ const headerContent = (
<>
- <span
- className={cn(
- "text-sm font-medium",
- isOpen ? "text-foreground" : "text-muted-foreground",
- )}
- >
- {title}
- </span>
- <div className="flex items-center gap-1">
- {children}
- {isCollapsible && (
- <HugeiconsIcon
- icon={ArrowDownIcon}
- className={cn(
- "size-3 shrink-0 transition-transform duration-200 ease-out",
- isOpen
- ? "rotate-0 text-foreground"
- : "-rotate-90 text-muted-foreground",
- )}
- />
- )}
- </div>
+ {leading}
+ <div className="min-w-0 flex-1">{children}</div>
+ {(trailing || chevronIcon) && (
+ <div className="flex items-center">
+ {trailing}
+ {chevronIcon && (
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={isOpen ? "Collapse section" : "Expand section"}
+ onClick={(event) => {
+ event.stopPropagation();
+ handleClick?.();
+ }}
+ >
+ {chevronIcon}
+ </Button>
+ )}
+ </div>
+ )}
+ {actions}
</>
);
- const baseClassName = cn(
- "flex w-full items-center justify-between h-11 px-3.5",
- className,
+ if (!isInteractive) {
+ return (
+ <div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}>
+ {headerContent}
+ </div>
+ );
+ }
+
+ 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>
);
+}
- if (isInteractive) {
+export function SectionTitle({
+ children,
+ className,
+ onClick,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ onClick?: () => void;
+}) {
+ const ctx = useSectionContext();
+ const isOpen = ctx?.isOpen ?? true;
+
+ if (onClick) {
return (
<button
type="button"
- className={cn(baseClassName, "cursor-pointer text-left")}
- onClick={(event) => {
- handleClick?.();
- event.currentTarget.blur();
- }}
+ className={cn(
+ "cursor-pointer text-sm font-medium",
+ isOpen ? "text-foreground" : "text-muted-foreground",
+ className,
+ )}
+ onClick={onClick}
>
- {content}
+ {children}
</button>
);
}
- return <div className={baseClassName}>{content}</div>;
+ return (
+ <span
+ className={cn(
+ "text-sm font-medium",
+ isOpen ? "text-foreground" : "text-muted-foreground",
+ className,
+ )}
+ >
+ {children}
+ </span>
+ );
}
export function SectionFields({
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
index 4343c692..66fe820f 100644
--- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx
+++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
@@ -8,7 +8,7 @@ import {
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
-import { Section, SectionContent, SectionField, SectionHeader } from "../section";
+import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
import {
Select,
SelectContent,
@@ -74,7 +74,7 @@ export function BlendingSection({
committedBlendModeRef.current = blendMode;
}
- const previewBlendMode = (value: BlendMode) =>
+ const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
@@ -123,7 +123,7 @@ export function BlendingSection({
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
- <SectionHeader title="Blending" />
+ <SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
<SectionField label="Opacity" className="w-1/2">
@@ -175,7 +175,7 @@ export function BlendingSection({
key={option.value}
value={option.value}
onPointerEnter={() =>
- previewBlendMode(option.value as BlendMode)
+ previewBlendMode({ value: option.value as BlendMode })
}
>
{option.label}
diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
index 3fdb47dc..6aaaa1fe 100644
--- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx
+++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
@@ -9,6 +9,7 @@ import {
SectionField,
SectionFields,
SectionHeader,
+ SectionTitle,
} from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
@@ -24,12 +25,12 @@ import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
import { KeyframeToggle } from "../keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-function parseNumericInput({ input }: { input: string }): number | null {
+export function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
-function isPropertyAtDefault({
+export function isPropertyAtDefault({
hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue,
@@ -55,9 +56,11 @@ function isPropertyAtDefault({
export function TransformSection({
element,
trackId,
+ showTopBorder = true,
}: {
element: VisualElement;
trackId: string;
+ showTopBorder?: boolean;
}) {
const editor = useEditor();
const [isScaleLocked, setIsScaleLocked] = useState(false);
@@ -239,8 +242,12 @@ export function TransformSection({
};
return (
- <Section collapsible sectionKey={`${element.type}:transform`}>
- <SectionHeader title="Transform" />
+ <Section
+ collapsible
+ sectionKey={`${element.type}:transform`}
+ showTopBorder={showTopBorder}
+ >
+ <SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
<SectionContent>
<SectionFields>
<SectionField
@@ -261,11 +268,11 @@ export function TransformSection({
<NumberField icon="H" {...scaleFieldProps} />
</>
) : (
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldProps}
- className="flex-1"
- />
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldProps}
+ className="flex-1"
+ />
)}
<Button
type="button"
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx
index 1a17c26a..73f3f1a3 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -3,13 +3,23 @@ import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/types/timeline";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
-import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_BACKGROUND,
@@ -20,30 +30,13 @@ import {
import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections";
import { HugeiconsIcon } from "@hugeicons/react";
-import { TextFontIcon } from "@hugeicons/core-free-icons";
+import {
+ TextFontIcon,
+ ViewIcon,
+ ViewOffSlashIcon,
+} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
-
-function createOffsetConverter({
- defaultValue,
- scale = 1,
- min,
-}: {
- defaultValue: number;
- scale?: number;
- min?: number;
-}) {
- return {
- toDisplay: (value: number) => Math.round((value - defaultValue) * scale),
- fromDisplay: (display: number) => {
- const stored = defaultValue + display / scale;
- return min !== undefined ? Math.max(min, stored) : stored;
- },
- };
-}
-
-const lineHeightConverter = createOffsetConverter({ defaultValue: DEFAULT_LINE_HEIGHT, scale: 10 });
-const paddingXConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, min: 0 });
-const paddingYConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, min: 0 });
+import { cn } from "@/utils/ui";
export function TextProperties({
element,
@@ -86,8 +79,8 @@ function ContentSection({
});
return (
- <Section collapsible sectionKey="text:content" hasBorderTop={false}>
- <SectionHeader title="Content" />
+ <Section collapsible sectionKey="text:content" showTopBorder={false}>
+ <SectionHeader><SectionTitle>Content</SectionTitle></SectionHeader>
<SectionContent>
<Textarea
placeholder="Name"
@@ -129,71 +122,71 @@ function TypographySection({
return (
<Section collapsible sectionKey="text:typography">
- <SectionHeader title="Typography" />
- <SectionContent>
- <SectionFields>
- <SectionField label="Font">
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value) =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontFamily: value },
- },
- ],
- })
- }
- />
- </SectionField>
- <SectionField label="Size">
- <NumberField
- value={fontSize.displayValue}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={fontSize.onFocus}
- onChange={fontSize.onChange}
- onBlur={fontSize.onBlur}
- onScrub={fontSize.scrubTo}
- onScrubEnd={fontSize.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
- },
- ],
- })
- }
- isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
- icon={<HugeiconsIcon icon={TextFontIcon} />}
- />
- </SectionField>
- <SectionField label="Color">
- <ColorPicker
- value={uppercase({
- string: (element.color || "FFFFFF").replace("#", ""),
- })}
- onChange={(color) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- })
- }
- onChangeEnd={() => editor.timeline.commitPreview()}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
+ <SectionHeader><SectionTitle>Typography</SectionTitle></SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Font">
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value) =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontFamily: value },
+ },
+ ],
+ })
+ }
+ />
+ </SectionField>
+ <SectionField label="Size">
+ <NumberField
+ value={fontSize.displayValue}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ onFocus={fontSize.onFocus}
+ onChange={fontSize.onChange}
+ onBlur={fontSize.onBlur}
+ onScrub={fontSize.scrubTo}
+ onScrubEnd={fontSize.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
+ },
+ ],
+ })
+ }
+ isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ icon={<HugeiconsIcon icon={TextFontIcon} />}
+ />
+ </SectionField>
+ <SectionField label="Color">
+ <ColorPicker
+ value={uppercase({
+ string: (element.color || "FFFFFF").replace("#", ""),
+ })}
+ onChange={(color) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { color: `#${color}` },
+ },
+ ],
+ })
+ }
+ onChangeEnd={() => editor.timeline.commitPreview()}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
</Section>
);
}
@@ -208,72 +201,98 @@ function SpacingSection({
const editor = useEditor();
const letterSpacing = usePropertyDraft({
- displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(),
+ displayValue: Math.round(
+ element.letterSpacing ?? DEFAULT_LETTER_SPACING,
+ ).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
onPreview: (value) =>
editor.timeline.previewElements({
- updates: [{ trackId, elementId: element.id, updates: { letterSpacing: value } }],
+ updates: [
+ { trackId, elementId: element.id, updates: { letterSpacing: value } },
+ ],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const lineHeight = usePropertyDraft({
- displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(),
+ displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed));
+ return Number.isNaN(parsed)
+ ? null
+ : Math.max(0.1, Math.round(parsed * 10) / 10);
},
onPreview: (value) =>
editor.timeline.previewElements({
- updates: [{ trackId, elementId: element.id, updates: { lineHeight: value } }],
+ updates: [
+ { trackId, elementId: element.id, updates: { lineHeight: value } },
+ ],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
- <Section collapsible sectionKey="text:spacing" hasBorderBottom={false}>
- <SectionHeader title="Spacing" />
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField label="Letter spacing" className="w-1/2">
- <NumberField
- value={letterSpacing.displayValue}
- onFocus={letterSpacing.onFocus}
- onChange={letterSpacing.onChange}
- onBlur={letterSpacing.onBlur}
- onScrub={letterSpacing.scrubTo}
- onScrubEnd={letterSpacing.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }],
- })
- }
- isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING}
- icon={<OcTextWidthIcon size={14} />}
- />
- </SectionField>
- <SectionField label="Line height" className="w-1/2">
- <NumberField
- value={lineHeight.displayValue}
- onFocus={lineHeight.onFocus}
- onChange={lineHeight.onChange}
- onBlur={lineHeight.onBlur}
- onScrub={lineHeight.scrubTo}
- onScrubEnd={lineHeight.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }],
- })
- }
- isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT}
- icon={<OcTextHeightIcon size={14} />}
- />
- </SectionField>
- </div>
- </SectionContent>
+ <Section collapsible sectionKey="text:spacing" showBottomBorder={false}>
+ <SectionHeader><SectionTitle>Spacing</SectionTitle></SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField label="Letter spacing" className="w-1/2">
+ <NumberField
+ value={letterSpacing.displayValue}
+ onFocus={letterSpacing.onFocus}
+ onChange={letterSpacing.onChange}
+ onBlur={letterSpacing.onBlur}
+ onScrub={letterSpacing.scrubTo}
+ onScrubEnd={letterSpacing.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { letterSpacing: DEFAULT_LETTER_SPACING },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.letterSpacing ?? DEFAULT_LETTER_SPACING) ===
+ DEFAULT_LETTER_SPACING
+ }
+ icon={<OcTextWidthIcon size={14} />}
+ />
+ </SectionField>
+ <SectionField label="Line height" className="w-1/2">
+ <NumberField
+ value={lineHeight.displayValue}
+ onFocus={lineHeight.onFocus}
+ onChange={lineHeight.onChange}
+ onBlur={lineHeight.onBlur}
+ onScrub={lineHeight.scrubTo}
+ onScrubEnd={lineHeight.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { lineHeight: DEFAULT_LINE_HEIGHT },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.lineHeight ?? DEFAULT_LINE_HEIGHT) ===
+ DEFAULT_LINE_HEIGHT
+ }
+ icon={<OcTextHeightIcon size={14} />}
+ />
+ </SectionField>
+ </div>
+ </SectionContent>
</Section>
);
}
@@ -289,10 +308,21 @@ function BackgroundSection({
const lastSelectedColor = useRef(DEFAULT_COLOR);
const cornerRadius = usePropertyDraft({
- displayValue: Math.round(element.background.cornerRadius ?? 0).toString(),
+ displayValue: Math.round(
+ clamp({
+ value: element.background.cornerRadius ?? 0,
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ }),
+ ).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ });
},
onPreview: (value) =>
editor.timeline.previewElements({
@@ -310,10 +340,12 @@ function BackgroundSection({
});
const paddingX = usePropertyDraft({
- displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(),
+ displayValue: Math.round(
+ element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ ).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed));
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
@@ -329,10 +361,12 @@ function BackgroundSection({
});
const paddingY = usePropertyDraft({
- displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(),
+ displayValue: Math.round(
+ element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ ).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed));
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
@@ -385,14 +419,63 @@ function BackgroundSection({
onCommit: () => editor.timeline.commitPreview(),
});
+ const toggleBackgroundEnabled = () => {
+ const enabled = !element.background.enabled;
+ const color =
+ enabled && element.background.color === "transparent"
+ ? lastSelectedColor.current
+ : element.background.color;
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: {
+ ...element.background,
+ enabled,
+ color,
+ },
+ },
+ },
+ ],
+ });
+ };
+
return (
- <Section collapsible sectionKey="text:background">
- <SectionHeader title="Background" />
- <SectionContent>
+ <Section
+ collapsible
+ defaultOpen={element.background.enabled}
+ sectionKey="text:background"
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleBackgroundEnabled();
+ }}
+ >
+ <HugeiconsIcon
+ icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Background</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(
+ !element.background.enabled && "pointer-events-none opacity-50",
+ )}
+ >
<SectionFields>
<SectionField label="Color">
<ColorPicker
value={
+ !element.background.enabled ||
element.background.color === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.background.color.replace("#", "")
@@ -415,19 +498,14 @@ function BackgroundSection({
});
}}
onChangeEnd={() => editor.timeline.commitPreview()}
- className={
- element.background.color === "transparent"
- ? "pointer-events-none opacity-50"
- : ""
- }
/>
</SectionField>
<div className="flex items-start gap-2">
- <SectionField label="Width" className="w-1/2">
- <NumberField
- icon="W"
- value={paddingX.displayValue}
- min={0}
+ <SectionField label="Width" className="w-1/2">
+ <NumberField
+ icon="W"
+ value={paddingX.displayValue}
+ min={0}
onFocus={paddingX.onFocus}
onChange={paddingX.onChange}
onBlur={paddingX.onBlur}
@@ -450,16 +528,17 @@ function BackgroundSection({
})
}
isDefault={
- (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) ===
+ (element.background.paddingX ??
+ DEFAULT_TEXT_BACKGROUND.paddingX) ===
DEFAULT_TEXT_BACKGROUND.paddingX
}
/>
</SectionField>
- <SectionField label="Height" className="w-1/2">
- <NumberField
- icon="H"
- value={paddingY.displayValue}
- min={0}
+ <SectionField label="Height" className="w-1/2">
+ <NumberField
+ icon="H"
+ value={paddingY.displayValue}
+ min={0}
onFocus={paddingY.onFocus}
onChange={paddingY.onChange}
onBlur={paddingY.onBlur}
@@ -482,17 +561,18 @@ function BackgroundSection({
})
}
isDefault={
- (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) ===
+ (element.background.paddingY ??
+ DEFAULT_TEXT_BACKGROUND.paddingY) ===
DEFAULT_TEXT_BACKGROUND.paddingY
}
/>
</SectionField>
</div>
<div className="flex items-start gap-2">
- <SectionField label="X-offset" className="w-1/2">
- <NumberField
- icon="X"
- value={offsetX.displayValue}
+ <SectionField label="X-offset" className="w-1/2">
+ <NumberField
+ icon="X"
+ value={offsetX.displayValue}
onFocus={offsetX.onFocus}
onChange={offsetX.onChange}
onBlur={offsetX.onBlur}
@@ -505,19 +585,19 @@ function BackgroundSection({
trackId,
elementId: element.id,
updates: {
- background: { ...element.background, offsetX: 0 },
- },
+ background: { ...element.background, offsetX: DEFAULT_TEXT_BACKGROUND.offsetX },
},
- ],
- })
- }
- isDefault={(element.background.offsetX ?? 0) === 0}
+ },
+ ],
+ })
+ }
+ isDefault={(element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX) === DEFAULT_TEXT_BACKGROUND.offsetX}
/>
</SectionField>
- <SectionField label="Y-offset" className="w-1/2">
- <NumberField
- icon="Y"
- value={offsetY.displayValue}
+ <SectionField label="Y-offset" className="w-1/2">
+ <NumberField
+ icon="Y"
+ value={offsetY.displayValue}
onFocus={offsetY.onFocus}
onChange={offsetY.onChange}
onBlur={offsetY.onBlur}
@@ -530,21 +610,22 @@ function BackgroundSection({
trackId,
elementId: element.id,
updates: {
- background: { ...element.background, offsetY: 0 },
- },
+ background: { ...element.background, offsetY: DEFAULT_TEXT_BACKGROUND.offsetY },
},
- ],
- })
- }
- isDefault={(element.background.offsetY ?? 0) === 0}
+ },
+ ],
+ })
+ }
+ isDefault={(element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY) === DEFAULT_TEXT_BACKGROUND.offsetY}
/>
</SectionField>
</div>
- <SectionField label="Corner Radius">
- <NumberField
- icon="R"
- value={cornerRadius.displayValue}
- min={0}
+ <SectionField label="Corner radius">
+ <NumberField
+ icon="R"
+ value={cornerRadius.displayValue}
+ min={CORNER_RADIUS_MIN}
+ max={CORNER_RADIUS_MAX}
onFocus={cornerRadius.onFocus}
onChange={cornerRadius.onChange}
onBlur={cornerRadius.onBlur}
@@ -557,16 +638,18 @@ function BackgroundSection({
trackId,
elementId: element.id,
updates: {
- background: {
- ...element.background,
- cornerRadius: 0,
- },
+ background: {
+ ...element.background,
+ cornerRadius: CORNER_RADIUS_MIN,
+ },
},
},
],
})
}
- isDefault={(element.background.cornerRadius ?? 0) === 0}
+ isDefault={
+ (element.background.cornerRadius ?? 0) === CORNER_RADIUS_MIN
+ }
/>
</SectionField>
</SectionFields>
diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx
index 3588cbb7..49dd8fe5 100644
--- a/apps/web/src/components/editor/panels/properties/video-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx
@@ -1,4 +1,8 @@
-import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline";
+import type {
+ ImageElement,
+ StickerElement,
+ VideoElement,
+} from "@/types/timeline";
import { BlendingSection, TransformSection } from "./sections";
export function VideoProperties({
@@ -10,7 +14,11 @@ export function VideoProperties({
}) {
return (
<div className="flex h-full flex-col">
- <TransformSection element={element} trackId={trackId} />
+ <TransformSection
+ element={element}
+ trackId={trackId}
+ showTopBorder={false}
+ />
<BlendingSection element={element} trackId={trackId} />
</div>
);