diff --git a/apps/web/content/changelog/0.2.0.md b/apps/web/content/changelog/0.2.0.md index 55aeeaed..53913e1d 100644 --- a/apps/web/content/changelog/0.2.0.md +++ b/apps/web/content/changelog/0.2.0.md @@ -14,4 +14,30 @@ changes: text: "Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below." - type: fixed text: "Audio could flicker or cut out at the start of playback when certain elements were selected. The scheduler now recovers from timing slips without losing audio." + - type: fixed + text: "Dragging a clip onto the timeline and pressing Ctrl+Z now removes both the clip and the track it created in one step, instead of requiring two undos." + - type: fixed + text: "A few values in the properties panel were previously showing incorrect values. This has been fixed." + - type: improved + text: "New text elements no longer have a black background by default." + - type: improved + text: "Selection outline in the preview is now dashed." + - type: improved + text: "Adjacent clips on the timeline now have a small gap between them so selected clips are always clearly visible." + - type: fixed + text: "Timeline trim handles now sit outside the clip instead of inside them, so the clip's visible area accurately represents where it starts and ends." + - type: fixed + text: "Clips being dragged on the timeline now render on top of other clips instead of underneath them." + - type: fixed + text: "Undoing a trim-from-start operation now correctly restores the clip to its original position and length, instead of stretching it to the right." + - type: fixed + text: "Resizing a clip on the timeline would deselect it when the mouse was released. The clip now stays selected after a resize." + - type: improved + text: "Press Escape to deselect the current element." + - type: fixed + text: "Resizing a clip could extend it past another clip on the same track, causing an overlap. The resize now stops at the next clip's edge." + - type: fixed + text: "Dragging a clip to a different track would deselect it on drop. The clip stays selected after moving." + - type: fixed + text: "Finishing a resize or drag with the mouse over empty track space would deselect the clip." --- diff --git a/apps/web/src/app/changelog/components/release.tsx b/apps/web/src/app/changelog/components/release.tsx index 5fed2f27..eb04c1f0 100644 --- a/apps/web/src/app/changelog/components/release.tsx +++ b/apps/web/src/app/changelog/components/release.tsx @@ -60,7 +60,7 @@ export function ReleaseTitle({ {href ? ( - <>{children} + {children} ) : ( children diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index bc44e650..ddf8308d 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef } from "react"; +import { useState } from "react"; import { TransitionTopIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { @@ -14,35 +14,47 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Progress } from "@/components/ui/progress"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/utils/ui"; -import { getExportMimeType, getExportFileExtension } from "@/lib/export"; +import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export"; import { Check, Copy, Download, RotateCcw } from "lucide-react"; import { EXPORT_FORMAT_VALUES, EXPORT_QUALITY_VALUES, type ExportFormat, type ExportQuality, - type ExportResult, } from "@/types/export"; import { Section, SectionContent, SectionHeader, + SectionTitle, } from "@/components/editor/panels/properties/section"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; +function isExportFormat(value: string): value is ExportFormat { + return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); +} + +function isExportQuality(value: string): value is ExportQuality { + return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); +} + export function ExportButton() { const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); const editor = useEditor(); - const handleExport = () => { - setIsExportPopoverOpen(true); + const hasProject = !!editor.project.getActiveOrNull(); + + const handlePopoverOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + editor.project.cancelExport(); + editor.project.clearExportState(); + } + setIsExportPopoverOpen(open); }; - const hasProject = !!editor.project.getActive(); - return ( - + handlePopoverOpenChange({ open })}> - - -

- {mediaViewMode === "grid" - ? "Switch to list view" - : "Switch to grid view"} -

-
- - - - - - - - - { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - - - -

- Sort by {sortBy} ( - {sortOrder === "asc" ? "ascending" : "descending"}) -

-
-
- - - - - ); + }, [mediaFiles, mediaSortBy, mediaSortOrder]); return ( <> @@ -327,8 +179,18 @@ export function MediaView() { + } + className={cn(isDragOver && "bg-accent/30")} {...dragProps} > {isDragOver || filteredMediaItems.length === 0 ? ( @@ -338,21 +200,11 @@ export function MediaView() { progress={progress} onClick={openFilePicker} /> - ) : mediaViewMode === "grid" ? ( - ) : ( - @@ -362,6 +214,67 @@ export function MediaView() { ); } +function MediaAssetDraggable({ + item, + preview, + isHighlighted, + variant, + isRounded, +}: { + item: MediaAsset; + preview: React.ReactNode; + isHighlighted: boolean; + variant: "card" | "compact"; + isRounded?: boolean; +}) { + const editor = useEditor(); + + const addElementAtTime = ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => { + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const element = buildElementFromMedia({ + mediaId: asset.id, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + }); + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + + addElementAtTime({ asset: item, startTime: currentTime }) + } + variant={variant} + isRounded={isRounded} + isHighlighted={isHighlighted} + /> + ); +} + function MediaItemWithContextMenu({ item, children, @@ -387,55 +300,41 @@ function MediaItemWithContextMenu({ ); } -function GridView({ +function MediaItemList({ items, - renderPreview, + mode, onRemove, - onAddToTimeline, highlightedId, registerElement, }: { items: MediaAsset[]; - renderPreview: (item: MediaAsset) => React.ReactNode; + mode: MediaViewMode; onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - onAddToTimeline: ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }) => boolean; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const isGrid = mode === "grid"; + return (
{items.map((item) => ( -
registerElement(item.id, el)}> +
registerElement(item.id, element)}> - - onAddToTimeline({ asset: item, startTime: currentTime }) + } - isRounded={false} - variant="card" + variant={isGrid ? "card" : "compact"} + isRounded={isGrid ? false : undefined} isHighlighted={highlightedId === item.id} /> @@ -445,63 +344,11 @@ function GridView({ ); } -function ListView({ - items, - renderPreview, - onRemove, - onAddToTimeline, - highlightedId, - registerElement, -}: { - items: MediaAsset[]; - renderPreview: (item: MediaAsset) => React.ReactNode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - onAddToTimeline: ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }) => boolean; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; -}) { - return ( -
- {items.map((item) => ( -
registerElement(item.id, el)}> - - - onAddToTimeline({ asset: item, startTime: currentTime }) - } - variant="compact" - isHighlighted={highlightedId === item.id} - /> - -
- ))} -
- ); -} - -const formatDuration = ({ duration }: { duration: number }) => { +export function formatDuration({ duration }: { duration: number }) { const min = Math.floor(duration / 60); const sec = Math.floor(duration % 60); return `${min}:${sec.toString().padStart(2, "0")}`; -}; +} function MediaDurationBadge({ duration }: { duration?: number }) { if (!duration) return null; @@ -619,6 +466,119 @@ function MediaPreview({ ); } +function MediaActions({ + mediaViewMode, + setMediaViewMode, + isProcessing, + sortBy, + sortOrder, + onSort, + onImport, +}: { + mediaViewMode: MediaViewMode; + setMediaViewMode: (mode: MediaViewMode) => void; + isProcessing: boolean; + sortBy: MediaSortKey; + sortOrder: MediaSortOrder; + onSort: ({ key }: { key: MediaSortKey }) => void; + onImport: () => void; +}) { + return ( +
+ + + + + + +

+ {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} +

+
+
+ + + + + + + + + + + + + + + +

+ Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) +

+
+
+
+ +
+ ); +} + function SortMenuItem({ label, sortKey, @@ -627,10 +587,10 @@ function SortMenuItem({ onSort, }: { label: string; - sortKey: "name" | "type" | "duration" | "size"; - currentSortBy: string; - currentSortOrder: "asc" | "desc"; - onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void; + sortKey: MediaSortKey; + currentSortBy: MediaSortKey; + currentSortOrder: MediaSortOrder; + onSort: ({ key }: { key: MediaSortKey }) => void; }) { const isActive = currentSortBy === sortKey; const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : ""; diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx index 7e10ac9d..d46e9d1f 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -16,6 +16,7 @@ import { Section, SectionContent, SectionHeader, + SectionTitle, } from "@/components/editor/panels/properties/section"; import { Label } from "@/components/ui/label"; import { @@ -23,25 +24,46 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { useState } from "react"; + +const ORIGINAL_PRESET_VALUE = "original"; + +export function findPresetIndexByAspectRatio({ + presets, + targetAspectRatio, +}: { + presets: Array<{ width: number; height: number }>; + targetAspectRatio: string; +}) { + for (let index = 0; index < presets.length; index++) { + const preset = presets[index]; + const presetAspectRatio = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + if (presetAspectRatio === targetAspectRatio) { + return index; + } + } + return -1; +} export function SettingsView() { - const [open, setOpen] = useState(false); - return (
-
+
- +
- -
+ } + > + Background
@@ -60,26 +82,6 @@ function ProjectInfoContent() { const activeProject = editor.project.getActive(); const { canvasPresets } = useEditorStore(); - const findPresetIndexByAspectRatio = ({ - presets, - targetAspectRatio, - }: { - presets: Array<{ width: number; height: number }>; - targetAspectRatio: string; - }) => { - for (let index = 0; index < presets.length; index++) { - const preset = presets[index]; - const presetAspectRatio = dimensionToAspectRatio({ - width: preset.width, - height: preset.height, - }); - if (presetAspectRatio === targetAspectRatio) { - return index; - } - } - return -1; - }; - const currentCanvasSize = activeProject.settings.canvasSize; const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; @@ -87,12 +89,11 @@ function ProjectInfoContent() { presets: canvasPresets, targetAspectRatio: currentAspectRatio, }); - const originalPresetValue = "original"; const selectedPresetValue = - presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; + presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE; const handleAspectRatioChange = ({ value }: { value: string }) => { - if (value === originalPresetValue) { + if (value === ORIGINAL_PRESET_VALUE) { const canvasSize = originalCanvasSize ?? currentCanvasSize; editor.project.updateSettings({ settings: { canvasSize }, @@ -106,7 +107,7 @@ function ProjectInfoContent() { } }; - const handleFpsChange = (value: string) => { + const handleFpsChange = ({ value }: { value: string }) => { const fps = parseFloat(value); editor.project.updateSettings({ settings: { fps } }); }; @@ -129,7 +130,7 @@ function ProjectInfoContent() { - Original + Original {canvasPresets.map((preset, index) => { const label = dimensionToAspectRatio({ width: preset.width, @@ -148,7 +149,7 @@ function ProjectInfoContent() { { + onPreview(selected); + onCommit(); + }} + > + + + + + {param.options.map((option) => ( + + {option.label} + + ))} + + + ); + } + + if (param.type === "color") { + return ( + 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 ( +
+ onPreview(newValue)} + onValueCommit={onCommit} + /> + +
+ ); +} 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 ( - -
- updateParam(value)} - onValueCommit={commitParam} - /> - -
-
- ); -} +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 ( -
- +
+ + {definition.name} + {definition.params.map((param) => ( editor.timeline.commitPreview()} /> ))} 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 ; } if (element.type === "audio") { - return ; + return ; } 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 (
- {hasSelection ? ( + {isShowingClipEffects ? ( + + ) : selectedElements.length > 0 ? ( {elementsWithTracks.map(({ track, element }) => ( (); +const mountedSectionKeys = new Set(); 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({
@@ -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 ? ( + + ) : null; + + const headerContent = ( <> - - {title} - -
- {children} - {isCollapsible && ( - - )} -
+ {leading} +
{children}
+ {(trailing || chevronIcon) && ( +
+ {trailing} + {chevronIcon && ( + + )} +
+ )} + {actions} ); - const baseClassName = cn( - "flex w-full items-center justify-between h-11 px-3.5", - className, - ); + if (!isInteractive) { + return ( +
+ {headerContent} +
+ ); + } - if (isInteractive) { + return ( + // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested ); } - return
{content}
; + return ( + + {children} + + ); } 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 (
- + Blending
@@ -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 ( -
- +
+ Transform ) : ( - } - {...scaleFieldProps} - className="flex-1" - /> + } + {...scaleFieldProps} + className="flex-1" + /> )}