diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-01 01:32:09 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-01 01:32:09 +0100 |
| commit | 5e7c27f542e94ffee5d2106ce318b6f43cc7e738 (patch) | |
| tree | b33c3370c01b9b2ee685f1039543bc02f8764f7b /apps/web/src/components/editor/panels | |
| parent | 744419469da403be57d30b5b19f6b43246caa57d (diff) | |
| parent | 354f4dec67ef7eb39d34fca941ca34025a68136b (diff) | |
| download | opencut-5e7c27f542e94ffee5d2106ce318b6f43cc7e738.tar.gz | |
Merge branch 'dev' into ripple-editing
Diffstat (limited to 'apps/web/src/components/editor/panels')
21 files changed, 1651 insertions, 480 deletions
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index 3600d00b..fe7db329 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -9,6 +9,7 @@ import { SettingsView } from "./views/settings"; import { SoundsView } from "./views/sounds"; import { StickersView } from "./views/stickers"; import { TextView } from "./views/text"; +import { EffectsView } from "./views/effects"; export function AssetsPanel() { const { activeTab } = useAssetsPanelStore(); @@ -18,11 +19,7 @@ export function AssetsPanel() { sounds: <SoundsView />, text: <TextView />, stickers: <StickersView />, - effects: ( - <div className="text-muted-foreground p-4"> - Effects view coming soon... - </div> - ), + effects: <EffectsView />, transitions: ( <div className="text-muted-foreground p-4"> Transitions view coming soon... diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 808961f1..4c6922ff 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -426,6 +426,9 @@ function GridView({ type: "media", mediaType: item.type, name: item.name, + ...(item.type !== "audio" && { + targetElementTypes: ["video", "image"] as const, + }), }} shouldShowPlusOnDrag={false} onAddToTimeline={({ currentTime }) => @@ -476,6 +479,9 @@ function ListView({ type: "media", mediaType: item.type, name: item.name, + ...(item.type !== "audio" && { + targetElementTypes: ["video", "image"] as const, + }), }} shouldShowPlusOnDrag={false} onAddToTimeline={({ currentTime }) => diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx new file mode 100644 index 00000000..c9b8b2dd --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects"; +import { + effectPreviewService, + onPreviewImageReady, +} from "@/services/renderer/effect-preview"; +import { useEditor } from "@/hooks/use-editor"; +import { buildEffectElement } from "@/lib/timeline/element-utils"; +import type { EffectDefinition } from "@/types/effects"; + +export function EffectsView() { + const effects = getAllEffects(); + + return ( + <PanelView title="Effects"> + <EffectsGrid effects={effects} /> + </PanelView> + ); +} + +function EffectsGrid({ effects }: { effects: EffectDefinition[] }) { + return ( + <div + className="grid gap-2" + style={{ gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))" }} + > + {effects.map((effect) => ( + <EffectItem key={effect.type} effect={effect} /> + ))} + </div> + ); +} + +function EffectPreviewCanvas({ effectType }: { effectType: string }) { + const canvasRef = useRef<HTMLCanvasElement>(null); + + useEffect(() => { + const render = () => { + if (canvasRef.current) { + effectPreviewService.renderPreview({ + effectType, + params: {}, + targetCanvas: canvasRef.current, + }); + } + }; + + render(); + return onPreviewImageReady({ callback: render }); + }, [effectType]); + + return <canvas ref={canvasRef} className="size-full" />; +} + +function EffectItem({ effect }: { effect: EffectDefinition }) { + const editor = useEditor(); + + const handleAddToTimeline = useCallback(() => { + const currentTime = editor.playback.getCurrentTime(); + const element = buildEffectElement({ + effectType: effect.type, + startTime: currentTime, + }); + + editor.timeline.insertElement({ + placement: { mode: "auto", trackType: "effect" }, + element, + }); + }, [editor, effect.type]); + + const preview = <EffectPreviewCanvas effectType={effect.type} />; + + return ( + <DraggableItem + name={effect.name} + preview={preview} + dragData={{ + id: effect.type, + name: effect.name, + type: "effect", + effectType: effect.type, + targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES, + }} + onAddToTimeline={handleAddToTimeline} + aspectRatio={1} + isRounded + variant="card" + containerClassName="w-full" + /> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 90cb11a3..df60db01 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -69,6 +69,7 @@ function RenderTreeController() { duration, canvasSize: { width, height }, background: activeProject.settings.background, + isPreview: true, }); editor.renderer.setRenderTree({ renderTree }); diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx index 265e972d..fdd4055d 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -99,9 +99,9 @@ export function TextEditOverlay({ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; const letterSpacing = element.letterSpacing ?? 0; const backgroundColor = - element.backgroundColor === "transparent" + element.background.color === "transparent" ? "transparent" - : element.backgroundColor; + : element.background.color; return ( <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 new file mode 100644 index 00000000..bf137291 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx @@ -0,0 +1,103 @@ +"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> + ); +} + +export function EffectProperties({ + element, + trackId, +}: { + element: EffectElement; + trackId: string; +}) { + const definition = getEffect({ effectType: element.effectType }); + + return ( + <Section hasBorderTop={false}> + <SectionHeader title={definition.name} /> + <SectionContent> + <SectionFields> + {definition.params.map((param) => ( + <EffectParamField + key={param.key} + param={param} + element={element} + trackId={trackId} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts new file mode 100644 index 00000000..cd62ca75 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -0,0 +1,151 @@ +import { useEditor } from "@/hooks/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/lib/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; +import type { ElementUpdatePatch } from "@/types/timeline"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedNumberProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + displayValue, + parse, + valueAtPlayhead, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + displayValue: string; + parse: (input: string) => number | null; + valueAtPlayhead: number; + buildBaseUpdates: ({ value }: { value: number }) => ElementUpdatePatch; +}) { + const editor = useEditor(); + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue = ({ value }: { value: number }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const propertyDraft = usePropertyDraft({ + displayValue, + parse, + onPreview: (value) => previewValue({ value }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: valueAtPlayhead, + }, + ], + }); + }; + + const commitValue = ({ value }: { value: number }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value, + }, + ], + }); + return; + } + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + return { + ...propertyDraft, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitValue, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 05ad5396..75dd81a9 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -4,9 +4,37 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { AudioProperties } from "./audio-properties"; import { VideoProperties } from "./video-properties"; import { TextProperties } from "./text-properties"; +import { EffectProperties } from "./effect-properties"; import { EmptyView } from "./empty-view"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; + +function ElementProperties({ + track, + element, +}: { + track: TimelineTrack; + element: TimelineElement; +}) { + if (element.type === "text") { + return <TextProperties element={element} trackId={track.id} />; + } + if (element.type === "audio") { + return <AudioProperties _element={element} />; + } + if ( + element.type === "video" || + element.type === "image" || + element.type === "sticker" + ) { + return <VideoProperties element={element} trackId={track.id} />; + } + if (element.type === "effect") { + return <EffectProperties element={element} trackId={track.id} />; + } + return null; +} export function PropertiesPanel() { const editor = useEditor(); @@ -16,30 +44,19 @@ export function PropertiesPanel() { elements: selectedElements, }); + const hasSelection = selectedElements.length > 0; + return ( <div className="panel bg-background h-full rounded-sm border overflow-hidden"> - {selectedElements.length > 0 ? ( - <ScrollArea className="h-full"> - {elementsWithTracks.map(({ track, element }) => { - if (element.type === "text") { - return ( - <div key={element.id}> - <TextProperties element={element} trackId={track.id} /> - </div> - ); - } - if (element.type === "audio") { - return <AudioProperties key={element.id} _element={element} />; - } - if (element.type === "video" || element.type === "image") { - return ( - <div key={element.id}> - <VideoProperties _element={element} /> - </div> - ); - } - return null; - })} + {hasSelection ? ( + <ScrollArea className="h-full scrollbar-hidden"> + {elementsWithTracks.map(({ track, element }) => ( + <ElementProperties + key={element.id} + track={track} + element={element} + /> + ))} </ScrollArea> ) : ( <EmptyView /> diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx new file mode 100644 index 00000000..b5b8685f --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx @@ -0,0 +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> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx index 0b45065f..3edd17b5 100644 --- a/apps/web/src/components/editor/panels/properties/section.tsx +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, 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"; const sectionExpandedCache = new Map<string, boolean>(); @@ -131,6 +132,40 @@ export function SectionHeader({ return <div className={baseClassName}>{content}</div>; } +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 items-center gap-1.5"> + {beforeLabel} + <Label>{label}</Label> + </div> + {children} + </div> + ); +} + export function SectionContent({ children, className, 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 430efa25..4343c692 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, SectionHeader } from "../section"; +import { Section, SectionContent, SectionField, SectionHeader } from "../section"; import { Select, SelectContent, @@ -124,73 +124,73 @@ export function BlendingSection({ return ( <Section collapsible sectionKey={`${element.type}:blending`}> <SectionHeader title="Blending" /> - <SectionContent> - <div className="flex items-start gap-2"> - <div className="w-1/2 space-y-1.5"> - <NumberField + <SectionContent> + <div className="flex items-start gap-2"> + <SectionField label="Opacity" className="w-1/2"> + <NumberField + className="w-full" + icon={ + <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> + } + value={opacity.displayValue} + min={0} + max={100} + onFocus={opacity.onFocus} + onChange={opacity.onChange} + onBlur={opacity.onBlur} + onScrub={opacity.scrubTo} + onScrubEnd={opacity.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: DEFAULT_OPACITY }, + }, + ], + }) + } + isDefault={element.opacity === DEFAULT_OPACITY} + dragSensitivity="slow" + /> + </SectionField> + <SectionField label="Blend mode" className="w-1/2"> + <Select + value={committedBlendModeRef.current} + onOpenChange={handleBlendModeOpenChange} + onValueChange={commitBlendMode} + > + <SelectTrigger + icon={<HugeiconsIcon icon={RainDropIcon} />} className="w-full" - icon={ - <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: DEFAULT_OPACITY }, - }, - ], - }) - } - isDefault={element.opacity === DEFAULT_OPACITY} - dragSensitivity="slow" - /> - </div> - <div className="w-1/2 space-y-1.5"> - <Select - value={committedBlendModeRef.current} - onOpenChange={handleBlendModeOpenChange} - onValueChange={commitBlendMode} > - <SelectTrigger - icon={<HugeiconsIcon icon={RainDropIcon} />} - className="w-full" - > - <SelectValue placeholder="Select blend mode" /> - </SelectTrigger> - <SelectContent className="w-36"> - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> - {group.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - onPointerEnter={() => - previewBlendMode(option.value as BlendMode) - } - > - {option.label} - </SelectItem> - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - <SelectSeparator /> - ) : null} - </Fragment> - ))} - </SelectContent> - </Select> - </div> - </div> - </SectionContent> + <SelectValue placeholder="Select blend mode" /> + </SelectTrigger> + <SelectContent className="w-36"> + {BLEND_MODE_GROUPS.map((group, groupIndex) => ( + <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> + {group.map((option) => ( + <SelectItem + key={option.value} + value={option.value} + onPointerEnter={() => + previewBlendMode(option.value as BlendMode) + } + > + {option.label} + </SelectItem> + ))} + {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( + <SelectSeparator /> + ) : null} + </Fragment> + ))} + </SelectContent> + </Select> + </SectionField> + </div> + </SectionContent> </Section> ); } diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx index 17a54b4c..3fdb47dc 100644 --- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -1,9 +1,15 @@ import { NumberField } from "@/components/ui/number-field"; import { useEditor } from "@/hooks/use-editor"; -import { usePropertyDraft } from "../hooks/use-property-draft"; -import { clamp } from "@/utils/math"; -import type { ElementType, Transform } from "@/types/timeline"; -import { Section, SectionContent, SectionHeader } from "../section"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import type { AnimationPropertyPath } from "@/types/animation"; +import type { VisualElement } from "@/types/timeline"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, +} from "../section"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { @@ -13,71 +19,125 @@ import { } from "@hugeicons/core-free-icons"; import { useState } from "react"; import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation"; +import { KeyframeToggle } from "../keyframe-toggle"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -type TransformElement = { - id: string; - transform: Transform; - type: ElementType; -}; - -function parseFloat_({ input }: { input: string }): number | null { +function parseNumericInput({ input }: { input: string }): number | null { const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : parsed; } +function isPropertyAtDefault({ + hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue, + staticValue, + defaultValue, +}: { + hasAnimatedKeyframes: boolean; + isPlayheadWithinElementRange: boolean; + resolvedValue: number; + staticValue: number; + defaultValue: number; +}): boolean { + if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { + return isNearlyEqual({ + leftValue: resolvedValue, + rightValue: defaultValue, + }); + } + + return staticValue === defaultValue; +} + export function TransformSection({ element, trackId, }: { - element: TransformElement; + element: VisualElement; trackId: string; }) { const editor = useEditor(); const [isScaleLocked, setIsScaleLocked] = useState(false); + const playheadTime = editor.playback.getCurrentTime(); + const localTime = getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const isPlayheadWithinElementRange = + playheadTime >= element.startTime - TIME_EPSILON_SECONDS && + playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS; - const previewTransform = (transform: Partial<Transform>) => { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { transform: { ...element.transform, ...transform } }, + const positionX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.position.x", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.position.x).toString(), + parse: (input) => parseNumericInput({ input }), + valueAtPlayhead: resolvedTransform.position.x, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + position: { + ...element.transform.position, + x: value, }, - ], - }); - }; - - const commit = () => editor.timeline.commitPreview(); - - const positionX = usePropertyDraft({ - displayValue: Math.round(element.transform.position.x).toString(), - parse: (input) => parseFloat_({ input }), - onPreview: (value) => - previewTransform({ - position: { ...element.transform.position, x: value }, - }), - onCommit: commit, + }, + }), }); - const positionY = usePropertyDraft({ - displayValue: Math.round(element.transform.position.y).toString(), - parse: (input) => parseFloat_({ input }), - onPreview: (value) => - previewTransform({ - position: { ...element.transform.position, y: value }, - }), - onCommit: commit, + const positionY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.position.y", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.position.y).toString(), + parse: (input) => parseNumericInput({ input }), + valueAtPlayhead: resolvedTransform.position.y, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + position: { + ...element.transform.position, + y: value, + }, + }, + }), }); - const scale = usePropertyDraft({ - displayValue: Math.round(element.transform.scale * 100).toString(), + const scale = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.scale", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.scale * 100).toString(), parse: (input) => { - const parsed = parseFloat_({ input }); + const parsed = parseNumericInput({ input }); if (parsed === null) return null; return Math.max(parsed, 1) / 100; }, - onPreview: (value) => previewTransform({ scale: value }), - onCommit: commit, + valueAtPlayhead: resolvedTransform.scale, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + scale: value, + }, + }), }); const scaleFieldProps = { className: "flex-1", @@ -88,160 +148,227 @@ export function TransformSection({ dragSensitivity: "slow" as const, onScrub: scale.scrubTo, onScrubEnd: scale.commitScrub, - onReset: () => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - transform: { - ...element.transform, - scale: DEFAULT_TRANSFORM.scale, - }, - }, - }, - ], - }), - isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale, + onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }), + isDefault: isPropertyAtDefault({ + hasAnimatedKeyframes: scale.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.scale, + staticValue: element.transform.scale, + defaultValue: DEFAULT_TRANSFORM.scale, + }), }; - const rotation = usePropertyDraft({ - displayValue: Math.round(element.transform.rotate).toString(), + const rotation = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.rotate", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.rotate).toString(), parse: (input) => { - const parsed = parseFloat_({ input }); + const parsed = parseNumericInput({ input }); if (parsed === null) return null; return clamp({ value: parsed, min: -360, max: 360 }); }, - onPreview: (value) => previewTransform({ rotate: value }), - onCommit: commit, + valueAtPlayhead: resolvedTransform.rotate, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + rotate: value, + }, + }), }); + const hasPositionKeyframe = + positionX.isKeyframedAtTime || positionY.isKeyframedAtTime; + + const togglePositionKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) { + const keyframesToRemove: Array<{ + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + }> = []; + if (positionX.keyframeIdAtTime) { + keyframesToRemove.push({ + trackId, + elementId: element.id, + propertyPath: "transform.position.x" as const, + keyframeId: positionX.keyframeIdAtTime, + }); + } + if (positionY.keyframeIdAtTime) { + keyframesToRemove.push({ + trackId, + elementId: element.id, + propertyPath: "transform.position.y" as const, + keyframeId: positionY.keyframeIdAtTime, + }); + } + + editor.timeline.removeKeyframes({ + keyframes: keyframesToRemove, + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId: element.id, + propertyPath: "transform.position.x", + time: localTime, + value: resolvedTransform.position.x, + }, + { + trackId, + elementId: element.id, + propertyPath: "transform.position.y", + time: localTime, + value: resolvedTransform.position.y, + }, + ], + }); + }; + return ( <Section collapsible sectionKey={`${element.type}:transform`}> <SectionHeader title="Transform" /> <SectionContent> - <div className="flex flex-col gap-2"> - <div className="flex items-center gap-2"> - {isScaleLocked ? ( - <> - <NumberField icon="W" {...scaleFieldProps} /> - <NumberField icon="H" {...scaleFieldProps} /> - </> - ) : ( + <SectionFields> + <SectionField + label="Scale" + beforeLabel={ + <KeyframeToggle + isActive={scale.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle scale keyframe" + onToggle={scale.toggleKeyframe} + /> + } + > + <div className="flex items-center gap-2"> + {isScaleLocked ? ( + <> + <NumberField icon="W" {...scaleFieldProps} /> + <NumberField icon="H" {...scaleFieldProps} /> + </> + ) : ( + <NumberField + icon={<HugeiconsIcon icon={ArrowExpandIcon} />} + {...scaleFieldProps} + className="flex-1" + /> + )} + <Button + type="button" + variant={isScaleLocked ? "secondary" : "ghost"} + size="icon" + aria-pressed={isScaleLocked} + onClick={() => setIsScaleLocked((isLocked) => !isLocked)} + > + <HugeiconsIcon icon={Link05Icon} /> + </Button> + </div> + </SectionField> + <SectionField + label="Position" + beforeLabel={ + <KeyframeToggle + isActive={hasPositionKeyframe} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle position keyframe" + onToggle={togglePositionKeyframe} + /> + } + > + <div className="flex items-center gap-2"> <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldProps} + icon="X" className="flex-1" + value={positionX.displayValue} + onFocus={positionX.onFocus} + onChange={positionX.onChange} + onBlur={positionX.onBlur} + onScrub={positionX.scrubTo} + onScrubEnd={positionX.commitScrub} + onReset={() => + positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.x, + staticValue: element.transform.position.x, + defaultValue: DEFAULT_TRANSFORM.position.x, + })} /> - )} - <Button - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setIsScaleLocked((isLocked) => !isLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - </div> - <div className="flex items-center gap-2"> - <NumberField - icon="X" - className="flex-1" - value={positionX.displayValue} - onFocus={positionX.onFocus} - onChange={positionX.onChange} - onBlur={positionX.onBlur} - onScrub={positionX.scrubTo} - onScrubEnd={positionX.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - transform: { - ...element.transform, - position: { - ...element.transform.position, - x: DEFAULT_TRANSFORM.position.x, - }, - }, - }, - }, - ], - }) - } - isDefault={ - element.transform.position.x === DEFAULT_TRANSFORM.position.x - } - /> - <NumberField - icon="Y" - className="flex-1" - value={positionY.displayValue} - onFocus={positionY.onFocus} - onChange={positionY.onChange} - onBlur={positionY.onBlur} - onScrub={positionY.scrubTo} - onScrubEnd={positionY.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - transform: { - ...element.transform, - position: { - ...element.transform.position, - y: DEFAULT_TRANSFORM.position.y, - }, - }, - }, - }, - ], - }) - } - isDefault={ - element.transform.position.y === DEFAULT_TRANSFORM.position.y - } - /> - </div> + <NumberField + icon="Y" + className="flex-1" + value={positionY.displayValue} + onFocus={positionY.onFocus} + onChange={positionY.onChange} + onBlur={positionY.onBlur} + onScrub={positionY.scrubTo} + onScrubEnd={positionY.commitScrub} + onReset={() => + positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.y, + staticValue: element.transform.position.y, + defaultValue: DEFAULT_TRANSFORM.position.y, + })} + /> + </div> + </SectionField> - <div className="flex items-center gap-2"> - <NumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - className="flex-1" - value={rotation.displayValue} - onFocus={rotation.onFocus} - onChange={rotation.onChange} - onBlur={rotation.onBlur} - dragSensitivity="slow" - onScrub={rotation.scrubTo} - onScrubEnd={rotation.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - transform: { - ...element.transform, - rotate: DEFAULT_TRANSFORM.rotate, - }, - }, - }, - ], - }) - } - isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate} - /> - </div> - </div> + <SectionField + label="Rotation" + beforeLabel={ + <KeyframeToggle + isActive={rotation.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle rotation keyframe" + onToggle={rotation.toggleKeyframe} + /> + } + > + <div className="flex items-center gap-2"> + <NumberField + icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} + className="flex-none" + value={rotation.displayValue} + onFocus={rotation.onFocus} + onChange={rotation.onChange} + onBlur={rotation.onBlur} + dragSensitivity="slow" + onScrub={rotation.scrubTo} + onScrubEnd={rotation.commitScrub} + onReset={() => + rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.rotate, + staticValue: element.transform.rotate, + defaultValue: DEFAULT_TRANSFORM.rotate, + })} + /> + </div> + </SectionField> + </SectionFields> </SectionContent> </Section> ); diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index cfca52dc..1a17c26a 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -1,31 +1,49 @@ import { Textarea } from "@/components/ui/textarea"; import { FontPicker } from "@/components/ui/font-picker"; import type { TextElement } from "@/types/timeline"; -import { Slider } from "@/components/ui/slider"; import { NumberField } from "@/components/ui/number-field"; import { useRef } from "react"; -import { Section, SectionContent, SectionHeader } from "./section"; +import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section"; import { ColorPicker } from "@/components/ui/color-picker"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; import { + DEFAULT_LETTER_SPACING, + DEFAULT_LINE_HEIGHT, + DEFAULT_TEXT_BACKGROUND, DEFAULT_TEXT_ELEMENT, MAX_FONT_SIZE, MIN_FONT_SIZE, } from "@/constants/text-constants"; import { usePropertyDraft } from "./hooks/use-property-draft"; import { TransformSection, BlendingSection } from "./sections"; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from "@/components/ui/select"; -import { OcFontWeightIcon } from "@opencut/ui/icons"; -import { Label } from "@/components/ui/label"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { TextFontIcon } 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 }); export function TextProperties({ element, @@ -39,8 +57,9 @@ export function TextProperties({ <ContentSection element={element} trackId={trackId} /> <TransformSection element={element} trackId={trackId} /> <BlendingSection element={element} trackId={trackId} /> - <FontSection element={element} trackId={trackId} /> - <ColorSection element={element} trackId={trackId} /> + <TypographySection element={element} trackId={trackId} /> + <SpacingSection element={element} trackId={trackId} /> + <BackgroundSection element={element} trackId={trackId} /> </div> ); } @@ -83,7 +102,7 @@ function ContentSection({ ); } -function FontSection({ +function TypographySection({ element, trackId, }: { @@ -109,10 +128,11 @@ function FontSection({ }); return ( - <Section collapsible sectionKey="text:font"> - <SectionHeader title="Font" /> - <SectionContent> - <div className="flex flex-col gap-2"> + <Section collapsible sectionKey="text:typography"> + <SectionHeader title="Typography" /> + <SectionContent> + <SectionFields> + <SectionField label="Font"> <FontPicker defaultValue={element.fontFamily} onValueChange={(value) => @@ -127,52 +147,409 @@ function FontSection({ }) } /> - <Select value={element.fontWeight}> - <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}> - <SelectValue placeholder="Select weight" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="100">Thin</SelectItem> - <SelectItem value="200">Extra Light</SelectItem> - <SelectItem value="300">Light</SelectItem> - <SelectItem value="400">Normal</SelectItem> - <SelectItem value="500">Medium</SelectItem> - <SelectItem value="600">Semi Bold</SelectItem> - <SelectItem value="700">Bold</SelectItem> - <SelectItem value="800">Extra Bold</SelectItem> - <SelectItem value="900">Black</SelectItem> - </SelectContent> - </Select> + </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> + ); +} + +function SpacingSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + + const letterSpacing = usePropertyDraft({ + displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.round(parsed); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [{ trackId, elementId: element.id, updates: { letterSpacing: value } }], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const lineHeight = usePropertyDraft({ + displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed)); + }, + onPreview: (value) => + editor.timeline.previewElements({ + 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> + ); +} - <Label>Font size</Label> - <div className="flex items-center gap-2"> - <Slider - value={[element.fontSize]} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - step={1} - onValueChange={([value]) => +function BackgroundSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_COLOR); + + const cornerRadius = usePropertyDraft({ + displayValue: Math.round(element.background.cornerRadius ?? 0).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { ...element.background, cornerRadius: value }, + }, + }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const paddingX = usePropertyDraft({ + displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed)); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { background: { ...element.background, paddingX: value } }, + }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const paddingY = usePropertyDraft({ + displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed)); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { background: { ...element.background, paddingY: value } }, + }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const offsetX = usePropertyDraft({ + displayValue: Math.round(element.background.offsetX ?? 0).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: { background: { ...element.background, offsetX: value } }, + }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const offsetY = usePropertyDraft({ + displayValue: Math.round(element.background.offsetY ?? 0).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: { background: { ...element.background, offsetY: value } }, + }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey="text:background"> + <SectionHeader title="Background" /> + <SectionContent> + <SectionFields> + <SectionField label="Color"> + <ColorPicker + value={ + element.background.color === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.background.color.replace("#", "") + } + onChange={(color) => { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, - updates: { fontSize: value }, + updates: { + background: { ...element.background, color: hexColor }, + }, }, ], - }) + }); + }} + onChangeEnd={() => editor.timeline.commitPreview()} + className={ + element.background.color === "transparent" + ? "pointer-events-none opacity-50" + : "" } - onValueCommit={() => editor.timeline.commitPreview()} - className="w-full" /> + </SectionField> + <div className="flex items-start gap-2"> + <SectionField label="Width" className="w-1/2"> <NumberField - className="w-18 shrink-0" - value={fontSize.displayValue} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={fontSize.onFocus} - onChange={fontSize.onChange} - onBlur={fontSize.onBlur} + icon="W" + value={paddingX.displayValue} + min={0} + onFocus={paddingX.onFocus} + onChange={paddingX.onChange} + onBlur={paddingX.onBlur} + onScrub={paddingX.scrubTo} + onScrubEnd={paddingX.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { + ...element.background, + paddingX: DEFAULT_TEXT_BACKGROUND.paddingX, + }, + }, + }, + ], + }) + } + isDefault={ + (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} + onFocus={paddingY.onFocus} + onChange={paddingY.onChange} + onBlur={paddingY.onBlur} + onScrub={paddingY.scrubTo} + onScrubEnd={paddingY.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { + ...element.background, + paddingY: DEFAULT_TEXT_BACKGROUND.paddingY, + }, + }, + }, + ], + }) + } + isDefault={ + (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} + onFocus={offsetX.onFocus} + onChange={offsetX.onChange} + onBlur={offsetX.onBlur} + onScrub={offsetX.scrubTo} + onScrubEnd={offsetX.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { ...element.background, offsetX: 0 }, + }, + }, + ], + }) + } + isDefault={(element.background.offsetX ?? 0) === 0} + /> + </SectionField> + <SectionField label="Y-offset" className="w-1/2"> + <NumberField + icon="Y" + value={offsetY.displayValue} + onFocus={offsetY.onFocus} + onChange={offsetY.onChange} + onBlur={offsetY.onBlur} + onScrub={offsetY.scrubTo} + onScrubEnd={offsetY.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { ...element.background, offsetY: 0 }, + }, + }, + ], + }) + } + isDefault={(element.background.offsetY ?? 0) === 0} + /> + </SectionField> + </div> + <SectionField label="Corner Radius"> + <NumberField + icon="R" + value={cornerRadius.displayValue} + min={0} + onFocus={cornerRadius.onFocus} + onChange={cornerRadius.onChange} + onBlur={cornerRadius.onBlur} + onScrub={cornerRadius.scrubTo} + onScrubEnd={cornerRadius.commitScrub} onReset={() => editor.timeline.updateElements({ updates: [ @@ -180,82 +557,19 @@ function FontSection({ trackId, elementId: element.id, updates: { - fontSize: DEFAULT_TEXT_ELEMENT.fontSize, + background: { + ...element.background, + cornerRadius: 0, + }, }, }, ], }) } - isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + isDefault={(element.background.cornerRadius ?? 0) === 0} /> - </div> - </div> - </SectionContent> - </Section> - ); -} - -function ColorSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_COLOR); - - return ( - <Section collapsible sectionKey="text:color" hasBorderBottom={false}> - <SectionHeader title="Color" /> - <SectionContent> - <div className="flex flex-col gap-6"> - <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()} - /> - <ColorPicker - value={ - element.backgroundColor === "transparent" - ? lastSelectedColor.current.replace("#", "") - : element.backgroundColor.replace("#", "") - } - onChange={(color) => { - const hexColor = `#${color}`; - if (color !== "transparent") { - lastSelectedColor.current = hexColor; - } - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: hexColor }, - }, - ], - }); - }} - onChangeEnd={() => editor.timeline.commitPreview()} - className={ - element.backgroundColor === "transparent" - ? "pointer-events-none opacity-50" - : "" - } - /> - </div> + </SectionField> + </SectionFields> </SectionContent> </Section> ); 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 bf6d92d8..3588cbb7 100644 --- a/apps/web/src/components/editor/panels/properties/video-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx @@ -1,9 +1,17 @@ -import type { ImageElement, VideoElement } from "@/types/timeline"; +import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline"; +import { BlendingSection, TransformSection } from "./sections"; export function VideoProperties({ - _element, + element, + trackId, }: { - _element: VideoElement | ImageElement; + element: VideoElement | ImageElement | StickerElement; + trackId: string; }) { - return <div className="space-y-4 p-5">Video properties</div>; + return ( + <div className="flex h-full flex-col"> + <TransformSection element={element} trackId={trackId} /> + <BlendingSection element={element} trackId={trackId} /> + </div> + ); } diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index 64824f9a..864bb557 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -5,10 +5,7 @@ import type { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; -import { - DEFAULT_BOOKMARK_COLOR, - TIMELINE_CONSTANTS, -} from "@/constants/timeline-constants"; +import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { getSnappedSeekTime } from "@/lib/time"; import { @@ -28,8 +25,8 @@ import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; +import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline"; -const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; const BOOKMARK_MARKER_HEIGHT_PX = 15; @@ -139,10 +136,12 @@ function TimelineBookmark({ const time = bookmark.time; const bookmarkDuration = bookmark.duration ?? 0; const durationWidth = - bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0; + bookmarkDuration > 0 + ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) + : 0; const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX; const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0); - const left = displayTime * PIXELS_PER_SECOND * zoomLevel; + const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel }); const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR; diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 7e6ff326..d89d270d 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -23,11 +23,11 @@ import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "../../selection-box"; import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; import { SnapIndicator } from "./snap-indicator"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; import type { TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS, - TRACK_ICONS, + TRACK_CONFIG, } from "@/constants/timeline-constants"; import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; import { @@ -161,6 +161,7 @@ export function Timeline() { shouldIgnoreClick, } = useSelectionBox({ containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, onSelectionComplete: (elements) => { setElementSelection({ elements }); }, @@ -326,7 +327,7 @@ export function Timeline() { <DragLine dropTarget={dropTarget} tracks={timeline.getTracks()} - isVisible={isDragOver} + isVisible={isDragOver && !dropTarget?.targetElement} headerHeight={timelineHeaderHeight} /> <DragLine @@ -453,6 +454,11 @@ export function Timeline() { }} onTrackClick={handleTracksClick} shouldIgnoreClick={shouldIgnoreClick} + targetElementId={ + isDragOver + ? dropTarget?.targetElement?.elementId ?? null + : null + } /> </div> </ContextMenuTrigger> @@ -523,7 +529,7 @@ export function Timeline() { } function TrackIcon({ track }: { track: TimelineTrack }) { - return <>{TRACK_ICONS[track.type]}</>; + return <>{TRACK_CONFIG[track.type].icon}</>; } function TrackToggleIcon({ diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index e70f0cd7..927d336a 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -1,7 +1,11 @@ "use client"; import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, +} from "@/lib/timeline"; import type { TimelineTrack } from "@/types/timeline"; interface SnapIndicatorProps { @@ -40,10 +44,10 @@ export function SnapIndicator({ <div className="pointer-events-none absolute" style={{ - left: `${leftPosition}px`, + left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`, top: topPosition, height: `${height}px`, - width: "2px", + width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, }} > <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 628855f6..38cf953b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -4,14 +4,17 @@ import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import AudioWaveform from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; +import { getElementKeyframes } from "@/lib/animation"; import { getTrackClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, hasMediaId, + timelineTimeToPixels, + timelineTimeToSnappedPixels, } from "@/lib/timeline"; import { ContextMenu, @@ -19,7 +22,7 @@ import { ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from "../../../ui/context-menu"; +} from "@/components/ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, @@ -42,10 +45,105 @@ import { VolumeMute02Icon, Search01Icon, Exchange01Icon, + KeyframeIcon, + MagicWand05Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; +import { cn } from "@/utils/ui"; + +const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; + +interface KeyframeIndicator { + time: number; + offsetPx: number; + keyframes: SelectedKeyframeRef[]; +} + +function buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, +}: { + keyframe: ElementKeyframe; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; +}): { + time: number; + offsetPx: number; + keyframeRef: SelectedKeyframeRef; +} { + const keyframeRef = { + trackId, + elementId, + propertyPath: keyframe.propertyPath, + keyframeId: keyframe.id, + }; + const keyframeLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime + keyframe.time, + zoomLevel, + }); + return { + time: keyframe.time, + offsetPx: keyframeLeft - elementLeft, + keyframeRef, + }; +} + +function getKeyframeIndicators({ + keyframes, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, +}: { + keyframes: ElementKeyframe[]; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + elementWidth: number; +}): KeyframeIndicator[] { + if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { + return []; + } + + const keyframesByTime = new Map<number, KeyframeIndicator>(); + for (const keyframe of keyframes) { + const indicator = buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + }); + const existingIndicator = keyframesByTime.get(indicator.time); + if (!existingIndicator) { + keyframesByTime.set(indicator.time, { + time: indicator.time, + offsetPx: indicator.offsetPx, + keyframes: [indicator.keyframeRef], + }); + continue; + } + + existingIndicator.keyframes.push(indicator.keyframeRef); + } + + return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); +} function getDisplayShortcut(action: TAction) { const { defaultShortcuts } = getActionDefinition(action); @@ -71,6 +169,7 @@ interface TimelineElementProps { ) => void; onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; + isDropTarget?: boolean; } export function TimelineElement({ @@ -83,6 +182,7 @@ export function TimelineElement({ onElementMouseDown, onElementClick, dragState, + isDropTarget = false, }: TimelineElementProps) { const editor = useEditor(); const { selectedElements } = useElementSelection(); @@ -123,10 +223,25 @@ export function TimelineElement({ : element.startTime; const displayedStartTime = isResizing ? currentStartTime : elementStartTime; const displayedDuration = isResizing ? currentDuration : element.duration; - const elementWidth = - displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const elementLeft = displayedStartTime * 50 * zoomLevel; - + const elementWidth = timelineTimeToPixels({ + time: displayedDuration, + zoomLevel, + }); + const elementLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime, + zoomLevel, + }); + const keyframeIndicators = isSelected + ? getKeyframeIndicators({ + keyframes: getElementKeyframes({ animations: element.animations }), + trackId: track.id, + elementId: element.id, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + }) + : []; const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -160,7 +275,9 @@ export function TimelineElement({ onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} + isDropTarget={isDropTarget} /> + {isSelected && <KeyframeIndicators indicators={keyframeIndicators} />} </div> </ContextMenuTrigger> <ContextMenuContent className="w-64"> @@ -197,7 +314,9 @@ export function TimelineElement({ <> <ContextMenuItem icon={<HugeiconsIcon icon={Search01Icon} />} - onClick={(event) => handleRevealInMedia({ event })} + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } > Reveal media </ContextMenuItem> @@ -231,6 +350,7 @@ function ElementInner({ onElementClick, onElementMouseDown, handleResizeStart, + isDropTarget = false, }: { element: TimelineElementType; track: TimelineTrack; @@ -248,14 +368,20 @@ function ElementInner({ elementId: string; side: "left" | "right"; }) => void; + isDropTarget?: boolean; }) { + const opacityClass = + (canElementBeHidden(element) && element.hidden) || isDropTarget + ? "opacity-50" + : ""; + return ( <div className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( { type: track.type, }, - )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + )} ${opacityClass}`} > <button type="button" @@ -271,7 +397,6 @@ function ElementInner({ mediaAssets={mediaAssets} /> </div> - {(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && ( @@ -335,52 +460,142 @@ function ResizeHandle({ ); } -function ElementContent({ - element, - track, - isSelected, - mediaAssets, +function KeyframeIndicators({ + indicators, }: { + indicators: KeyframeIndicator[]; +}) { + const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } = + useKeyframeSelection(); + const orderedKeyframes = indicators.flatMap( + (indicator) => indicator.keyframes, + ); + + const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const handleKeyframeClick = ({ + event, + keyframes, + }: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => { + event.stopPropagation(); + if (event.shiftKey) { + selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + }; + + return indicators.map((indicator) => { + const isIndicatorSelected = indicator.keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + + return ( + <button + key={indicator.time} + type="button" + className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer" + style={{ left: indicator.offsetPx }} + onMouseDown={(event) => handleKeyframeMouseDown({ event })} + onClick={(event) => + handleKeyframeClick({ event, keyframes: indicator.keyframes }) + } + aria-label="Select keyframe" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn( + "size-3.5 text-black", + isIndicatorSelected ? "fill-primary" : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + ); + }); +} + +interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; mediaAssets: MediaAsset[]; -}) { - if (element.type === "text") { +} + +type ElementContentRenderer = (props: ElementContentProps) => ReactNode; + +const ELEMENT_CONTENT_RENDERERS: Record< + TimelineElementType["type"], + ElementContentRenderer +> = { + text: ({ element }) => { + const textElement = element as Extract< + TimelineElementType, + { type: "text" } + >; return ( - <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white">{element.content}</span> + <div className="flex size-full items-center justify-start pl-3"> + <span className="truncate text-xs text-white"> + {textElement.content} + </span> </div> ); - } - - if (element.type === "sticker") { + }, + effect: ({ element }) => ( + <div className="flex size-full items-center justify-start gap-1 pl-2"> + <HugeiconsIcon icon={MagicWand05Icon} className="size-4 shrink-0 text-white" /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ), + sticker: ({ element }) => { + const stickerElement = element as Extract< + TimelineElementType, + { type: "sticker" } + >; return ( <div className="flex size-full items-center gap-2 pl-2"> <Image src={resolveStickerId({ - stickerId: element.stickerId, + stickerId: stickerElement.stickerId, options: { width: 20, height: 20 }, })} - alt={element.name} + alt={stickerElement.name} className="size-5 shrink-0" width={20} height={20} unoptimized /> - <span className="truncate text-xs text-white">{element.name}</span> + <span className="truncate text-xs text-white"> + {stickerElement.name} + </span> </div> ); - } - - if (element.type === "audio") { + }, + audio: ({ element, mediaAssets }) => { + const audioElement = element as Extract< + TimelineElementType, + { type: "audio" } + >; const audioBuffer = - element.sourceType === "library" ? element.buffer : undefined; - + audioElement.sourceType === "library" ? audioElement.buffer : undefined; const audioUrl = - element.sourceType === "library" - ? element.sourceUrl - : mediaAssets.find((asset) => asset.id === element.mediaId)?.url; + audioElement.sourceType === "library" + ? audioElement.sourceUrl + : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url; if (audioBuffer || audioUrl) { return ( @@ -399,28 +614,78 @@ function ElementContent({ return ( <span className="text-foreground/80 truncate text-xs"> - {element.name} + {audioElement.name} </span> ); - } + }, + video: ({ element, track, isSelected, mediaAssets }) => { + const videoElement = element as Extract< + TimelineElementType, + { type: "video" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === videoElement.mediaId, + ); + + if (!mediaAsset) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {videoElement.name} + </span> + ); + } + + if (mediaAsset.thumbnailUrl) { + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + + return ( + <div className="flex size-full items-center justify-center"> + <div + className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`} + > + <div + className="absolute right-0 left-0" + style={{ + backgroundImage: `url(${mediaAsset.thumbnailUrl})`, + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + top: isSelected ? "0.25rem" : "0rem", + bottom: isSelected ? "0.25rem" : "0rem", + }} + /> + </div> + </div> + ); + } - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - if (!mediaAsset) { return ( <span className="text-foreground/80 truncate text-xs"> - {element.name} + {videoElement.name} </span> ); - } + }, + image: ({ element, track, isSelected, mediaAssets }) => { + const imageElement = element as Extract< + TimelineElementType, + { type: "image" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === imageElement.mediaId, + ); + + if (!mediaAsset?.url) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {imageElement.name} + </span> + ); + } - if ( - mediaAsset.type === "image" || - (mediaAsset.type === "video" && mediaAsset.thumbnailUrl) - ) { const trackHeight = getTrackHeight({ type: track.type }); const tileWidth = trackHeight * (16 / 9); - const imageUrl = - mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl; return ( <div className="flex size-full items-center justify-center"> @@ -430,7 +695,7 @@ function ElementContent({ <div className="absolute right-0 left-0" style={{ - backgroundImage: imageUrl ? `url(${imageUrl})` : "none", + backgroundImage: `url(${mediaAsset.url})`, backgroundRepeat: "repeat-x", backgroundSize: `${tileWidth}px ${trackHeight}px`, backgroundPosition: "left center", @@ -442,11 +707,12 @@ function ElementContent({ </div> </div> ); - } + }, +}; - return ( - <span className="text-foreground/80 truncate text-xs">{element.name}</span> - ); +function ElementContent(props: ElementContentProps) { + const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type]; + return <>{renderer(props)}</>; } function CopyMenuItem() { @@ -549,10 +815,11 @@ function ActionMenuItem({ ...props }: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { action: TAction; + children: ReactNode; }) { return ( <ContextMenuItem - onClick={(event) => { + onClick={(event: React.MouseEvent) => { event.stopPropagation(); invokeAction(action); }} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index ebb2a37e..e290259e 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -1,7 +1,11 @@ "use client"; import { useRef } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, + timelineTimeToSnappedPixels, +} from "@/lib/timeline"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useEditor } from "@/hooks/use-editor"; @@ -43,9 +47,11 @@ export function TimelinePlayhead({ 400; const totalHeight = Math.max(0, timelineContainerHeight - 4); - const timelinePosition = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const leftPosition = timelinePosition; + const centerPosition = timelineTimeToSnappedPixels({ + time: playheadPosition, + zoomLevel, + }); + const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); const handlePlayheadKeyDown = ( event: React.KeyboardEvent<HTMLDivElement>, @@ -77,7 +83,7 @@ export function TimelinePlayhead({ left: `${leftPosition}px`, top: 0, height: `${totalHeight}px`, - width: "2px", + width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, }} onKeyDown={handlePlayheadKeyDown} > diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 9402d25e..22cd52cf 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx @@ -1,6 +1,6 @@ "use client"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; interface TimelineTickProps { @@ -16,7 +16,7 @@ export function TimelineTick({ fps, showLabel, }: TimelineTickProps) { - const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel }); if (showLabel) { const label = formatRulerLabel({ timeInSeconds: time, fps }); diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index b7b06d5b..fbc7cb3f 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -4,7 +4,7 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-select import { TimelineElement } from "./timeline-element"; import type { TimelineTrack } from "@/types/timeline"; import type { TimelineElement as TimelineElementType } from "@/types/timeline"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import type { ElementDragState } from "@/types/timeline"; @@ -32,6 +32,7 @@ interface TimelineTrackContentProps { onTrackMouseDown?: (event: React.MouseEvent) => void; onTrackClick?: (event: React.MouseEvent) => void; shouldIgnoreClick?: () => boolean; + targetElementId?: string | null; } export function TimelineTrackContent({ @@ -48,6 +49,7 @@ export function TimelineTrackContent({ onTrackMouseDown, onTrackClick, shouldIgnoreClick, + targetElementId = null, }: TimelineTrackContentProps) { const editor = useEditor(); const { isElementSelected, clearElementSelection } = useElementSelection(); @@ -102,6 +104,7 @@ export function TimelineTrackContent({ onElementClick({ event, element, track }) } dragState={dragState} + isDropTarget={element.id === targetElementId} /> ); }) |
