summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
commit5e7c27f542e94ffee5d2106ce318b6f43cc7e738 (patch)
treeb33c3370c01b9b2ee685f1039543bc02f8764f7b /apps/web/src/components
parent744419469da403be57d30b5b19f6b43246caa57d (diff)
parent354f4dec67ef7eb39d34fca941ca34025a68136b (diff)
downloadopencut-5e7c27f542e94ffee5d2106ce318b6f43cc7e738.tar.gz
Merge branch 'dev' into ripple-editing
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx7
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx6
-rw-r--r--apps/web/src/components/editor/panels/assets/views/effects.tsx95
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx1
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx4
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-properties.tsx103
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts151
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx61
-rw-r--r--apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx32
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx35
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx132
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx495
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx560
-rw-r--r--apps/web/src/components/editor/panels/properties/video-properties.tsx16
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx13
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx14
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx10
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx371
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx16
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx4
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx5
-rw-r--r--apps/web/src/components/landing/hero.tsx2
-rw-r--r--apps/web/src/components/ui/color-picker.tsx18
-rw-r--r--apps/web/src/components/ui/font-picker.tsx4
-rw-r--r--apps/web/src/components/ui/number-field.tsx7
-rw-r--r--apps/web/src/components/ui/select.tsx2
-rw-r--r--apps/web/src/components/ui/slider.tsx4
27 files changed, 1676 insertions, 492 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}
/>
);
})
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 6f11ad7c..a8f3f70d 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -14,7 +14,7 @@ export function Hero() {
src="/landing-page-dark.png"
height={1903.5}
width={1269}
- alt="landing-page.bg"
+ alt="OpenCut video editor landing page background"
/>
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center">
<div className="inline-block text-4xl font-bold tracking-tighter md:text-[4rem]">
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
index d58b6144..fbf9bbd7 100644
--- a/apps/web/src/components/ui/color-picker.tsx
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -280,13 +280,21 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
)}
{...props}
>
- <PopoverTrigger asChild>
- <button
- className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20"
+ <PopoverTrigger asChild>
+ <button
+ className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
+ type="button"
+ >
+ <span
+ className="absolute inset-0"
+ style={checkerboardStyle}
+ />
+ <span
+ className="absolute inset-0"
style={{ backgroundColor: `#${value}` }}
- type="button"
/>
- </PopoverTrigger>
+ </button>
+ </PopoverTrigger>
<div className="flex flex-1 items-center">
<Input
className={cn(
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index d5d56255..09ba0a9a 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -26,7 +26,7 @@ import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
import { cn } from "@/utils/ui";
import { ChevronDown, Search, Upload } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
-import { TextFontIcon } from "@hugeicons/core-free-icons";
+import { TextIcon } from "@hugeicons/core-free-icons";
const FONT_TABS = [
{ key: "all", label: "All fonts" },
@@ -139,7 +139,7 @@ export function FontPicker({
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
- <HugeiconsIcon icon={TextFontIcon} />
+ <HugeiconsIcon icon={TextIcon} />
</span>
<span className="truncate" style={{ fontFamily: defaultValue }}>
{defaultValue ?? "Select a font"}
diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx
index 35cce81b..48297dc0 100644
--- a/apps/web/src/components/ui/number-field.tsx
+++ b/apps/web/src/components/ui/number-field.tsx
@@ -71,9 +71,12 @@ function NumberField({
return;
}
cumulativeDeltaRef.current += moveEvent.movementX;
+ const sensitivity =
+ typeof dragSensitivity === "number"
+ ? dragSensitivity
+ : DRAG_SENSITIVITIES[dragSensitivity];
const newValue =
- startValueRef.current +
- cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
+ startValueRef.current + cumulativeDeltaRef.current * sensitivity;
onScrub(newValue);
};
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 311f6edb..1b57ea4d 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -40,7 +40,7 @@ const selectTriggerVariants = cva(
},
size: {
default: "",
- sm: "",
+ sm: "rounded-sm",
},
},
defaultVariants: {
diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx
index 57ddbcf0..700be644 100644
--- a/apps/web/src/components/ui/slider.tsx
+++ b/apps/web/src/components/ui/slider.tsx
@@ -7,7 +7,9 @@ import { cn } from "@/utils/ui";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
+ className?: string;
+ }
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}