diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..4ddf2a88
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,41 @@
+## **0.2.0**:
+
+This release adds the foundation for motion and effects.
+
+**Features**:
+
+- Keyframe animation system, starting with transform properties (position, scale, rotation).
+- Effects system (with our first effect: Blur!)
+
+**Bug fixes**:
+
+- Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below
+
+**Improvements**:
+
+## **0.1.0**:
+
+This first release focuses on making editing faster, clearer, and more reliable for day-to-day use.
+
+**Features**:
+
+- Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in.
+- The properties panel went from a flat list of basic text styles to organized sections: transform, blending, typography, spacing, and background controls. Text elements finally have position, scale, and rotation.
+- New color picker with eyedropper, opacity control, and multiple color formats.
+- Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly.
+- Move, scale, and rotate elements directly in the preview panel.
+- Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now.
+- Paste images, videos, or audio from your clipboard straight into the editor.
+- Hold `Shift` while moving or resizing to temporarily turn off snapping.
+
+**Improvements**:
+
+- Expanded font selection from 7 to over 1,000.
+- Fonts load much faster.
+- All asset panel tabs now share a consistent layout.
+- Text rendering properly supports multiple lines, line height, and letter spacing.
+- Better contrast in the dark theme.
+
+**Bug fixes**:
+
+- Fixed undo/redo for drag-and-drop so dropped items are reliably undoable.
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index fa1ac7f5..c3676f0f 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
const nextConfig: NextConfig = {
+ turbopack: {
+ rules: {
+ "*.glsl": {
+ loaders: [require.resolve("raw-loader")],
+ as: "*.js",
+ },
+ },
+ },
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
diff --git a/apps/web/package.json b/apps/web/package.json
index 9a57a379..90aa5d3b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,107 +1,108 @@
{
- "name": "@opencut/web",
- "version": "0.1.0",
- "private": true,
- "packageManager": "bun@1.2.18",
- "scripts": {
- "dev": "next dev --turbopack",
- "build": "next build",
- "start": "next start",
- "lint": "biome check src/",
- "lint:fix": "biome check src/ --write",
- "format": "biome format src/ --write",
- "db:generate": "drizzle-kit generate",
- "db:migrate": "drizzle-kit migrate",
- "db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
- "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
- },
- "dependencies": {
- "@ffmpeg/core": "^0.12.10",
- "@ffmpeg/ffmpeg": "^0.12.15",
- "@ffmpeg/util": "^0.12.2",
- "@hello-pangea/dnd": "^18.0.1",
- "@hookform/resolvers": "^3.9.1",
- "@hugeicons/core-free-icons": "^3.1.1",
- "@hugeicons/react": "^1.1.4",
- "@huggingface/transformers": "^3.8.1",
- "@opencut/env": "workspace:*",
- "@opencut/ui": "workspace:*",
- "@radix-ui/react-accordion": "^1.2.12",
- "@radix-ui/react-checkbox": "^1.3.3",
- "@radix-ui/react-dialog": "^1.1.15",
- "@radix-ui/react-dropdown-menu": "^2.1.16",
- "@radix-ui/react-primitive": "^2.1.4",
- "@radix-ui/react-select": "^2.2.6",
- "@radix-ui/react-separator": "^1.1.8",
- "@radix-ui/react-slot": "^1.2.4",
- "@radix-ui/react-tooltip": "^1.2.8",
- "@types/culori": "^4.0.1",
- "@upstash/ratelimit": "^2.0.6",
- "@upstash/redis": "^1.35.4",
- "@vercel/analytics": "^1.4.1",
- "aws4fetch": "^1.0.20",
- "better-auth": "^1.2.7",
- "botid": "^1.4.2",
- "class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
- "cmdk": "^1.0.0",
- "culori": "^4.0.2",
- "dayjs": "^1.11.13",
- "drizzle-orm": "^0.44.2",
- "embla-carousel-react": "^8.5.1",
- "eventemitter3": "^5.0.1",
- "feed": "^5.1.0",
- "input-otp": "^1.4.1",
- "lucide-react": "^0.562.0",
- "mediabunny": "^1.29.1",
- "motion": "^12.18.1",
- "nanoid": "^5.1.5",
- "next": "16.1.3",
- "next-themes": "^0.4.4",
- "pg": "^8.16.2",
- "postgres": "^3.4.5",
- "radix-ui": "^1.4.3",
- "react": "^19.0.0",
- "react-day-picker": "^8.10.1",
- "react-dom": "^19.0.0",
- "react-hook-form": "^7.54.0",
- "react-icons": "^5.4.0",
- "react-markdown": "^10.1.0",
- "react-phone-number-input": "^3.4.11",
- "react-resizable-panels": "^2.1.7",
- "react-window": "^2.2.7",
- "recharts": "^2.14.1",
- "rehype-autolink-headings": "^7.1.0",
- "rehype-parse": "^9.0.1",
- "rehype-sanitize": "^6.0.0",
- "rehype-slug": "^6.0.0",
- "rehype-stringify": "^10.0.1",
- "sonner": "^1.7.1",
- "tailwind-merge": "^2.5.5",
- "tailwindcss-animate": "^1.0.7",
- "unified": "^11.0.5",
- "use-deep-compare-effect": "^1.8.1",
- "vaul": "^1.1.2",
- "wavesurfer.js": "^7.9.8",
- "zod": "^3.25.67",
- "zustand": "^5.0.2"
- },
- "devDependencies": {
- "@napi-rs/canvas": "^0.1.92",
- "@tailwindcss/postcss": "^4.1.11",
- "@tailwindcss/typography": "^0.5.16",
- "@types/bun": "latest",
- "@types/node": "^24.2.1",
- "@types/pg": "^8.15.4",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "cross-env": "^7.0.3",
- "dotenv": "^16.5.0",
- "drizzle-kit": "^0.31.4",
- "postcss": "^8",
- "sharp": "^0.34.5",
- "tailwindcss": "^4.1.11",
- "tsx": "^4.7.1",
- "typescript": "^5.8.3"
- }
+ "name": "@opencut/web",
+ "version": "0.1.0",
+ "private": true,
+ "packageManager": "bun@1.2.18",
+ "scripts": {
+ "dev": "next dev --turbopack",
+ "build": "next build",
+ "start": "next start",
+ "lint": "biome check src/",
+ "lint:fix": "biome check src/ --write",
+ "format": "biome format src/ --write",
+ "db:generate": "drizzle-kit generate",
+ "db:migrate": "drizzle-kit migrate",
+ "db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
+ "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
+ },
+ "dependencies": {
+ "@ffmpeg/core": "^0.12.10",
+ "@ffmpeg/ffmpeg": "^0.12.15",
+ "@ffmpeg/util": "^0.12.2",
+ "@hello-pangea/dnd": "^18.0.1",
+ "@hookform/resolvers": "^3.9.1",
+ "@hugeicons/core-free-icons": "^3.1.1",
+ "@hugeicons/react": "^1.1.4",
+ "@huggingface/transformers": "^3.8.1",
+ "@opencut/env": "workspace:*",
+ "@opencut/ui": "workspace:*",
+ "@radix-ui/react-accordion": "^1.2.12",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-primitive": "^2.1.4",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@types/culori": "^4.0.1",
+ "@upstash/ratelimit": "^2.0.6",
+ "@upstash/redis": "^1.35.4",
+ "@vercel/analytics": "^1.4.1",
+ "aws4fetch": "^1.0.20",
+ "better-auth": "^1.2.7",
+ "botid": "^1.4.2",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.0.0",
+ "culori": "^4.0.2",
+ "dayjs": "^1.11.13",
+ "drizzle-orm": "^0.44.2",
+ "embla-carousel-react": "^8.5.1",
+ "eventemitter3": "^5.0.1",
+ "feed": "^5.1.0",
+ "input-otp": "^1.4.1",
+ "lucide-react": "^0.562.0",
+ "mediabunny": "^1.29.1",
+ "motion": "^12.18.1",
+ "nanoid": "^5.1.5",
+ "next": "16.1.3",
+ "next-themes": "^0.4.4",
+ "pg": "^8.16.2",
+ "postgres": "^3.4.5",
+ "radix-ui": "^1.4.3",
+ "react": "^19.0.0",
+ "react-day-picker": "^8.10.1",
+ "react-dom": "^19.0.0",
+ "react-hook-form": "^7.54.0",
+ "react-icons": "^5.4.0",
+ "react-markdown": "^10.1.0",
+ "react-phone-number-input": "^3.4.11",
+ "react-resizable-panels": "^2.1.7",
+ "react-window": "^2.2.7",
+ "recharts": "^2.14.1",
+ "rehype-autolink-headings": "^7.1.0",
+ "rehype-parse": "^9.0.1",
+ "rehype-sanitize": "^6.0.0",
+ "rehype-slug": "^6.0.0",
+ "rehype-stringify": "^10.0.1",
+ "sonner": "^1.7.1",
+ "tailwind-merge": "^2.5.5",
+ "tailwindcss-animate": "^1.0.7",
+ "unified": "^11.0.5",
+ "use-deep-compare-effect": "^1.8.1",
+ "vaul": "^1.1.2",
+ "wavesurfer.js": "^7.9.8",
+ "zod": "^3.25.67",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@napi-rs/canvas": "^0.1.92",
+ "@tailwindcss/postcss": "^4.1.11",
+ "@tailwindcss/typography": "^0.5.16",
+ "@types/bun": "latest",
+ "@types/node": "^24.2.1",
+ "@types/pg": "^8.15.4",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "cross-env": "^7.0.3",
+ "dotenv": "^16.5.0",
+ "drizzle-kit": "^0.31.4",
+ "postcss": "^8",
+ "raw-loader": "^4.0.2",
+ "sharp": "^0.34.5",
+ "tailwindcss": "^4.1.11",
+ "tsx": "^4.7.1",
+ "typescript": "^5.8.3"
+ }
}
diff --git a/apps/web/public/effects/preview.jpg b/apps/web/public/effects/preview.jpg
new file mode 100644
index 00000000..6519189c
Binary files /dev/null and b/apps/web/public/effects/preview.jpg differ
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: ,
text: ,
stickers: ,
- effects: (
-
- Effects view coming soon...
-
- ),
+ effects: ,
transitions: (
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 (
+
+
+
+ );
+}
+
+function EffectsGrid({ effects }: { effects: EffectDefinition[] }) {
+ return (
+
+ {effects.map((effect) => (
+
+ ))}
+
+ );
+}
+
+function EffectPreviewCanvas({ effectType }: { effectType: string }) {
+ const canvasRef = useRef
(null);
+
+ useEffect(() => {
+ const render = () => {
+ if (canvasRef.current) {
+ effectPreviewService.renderPreview({
+ effectType,
+ params: {},
+ targetCanvas: canvasRef.current,
+ });
+ }
+ };
+
+ render();
+ return onPreviewImageReady({ callback: render });
+ }, [effectType]);
+
+ return ;
+}
+
+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 = ;
+
+ return (
+
+ );
+}
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 (
+
+
+ updateParam(value)}
+ onValueCommit={commitParam}
+ />
+
+
+
+ );
+}
+
+export function EffectProperties({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const definition = getEffect({ effectType: element.effectType });
+
+ return (
+
+
+
+
+ {definition.params.map((param) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index 4f285f84..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 ;
+ }
+ if (element.type === "audio") {
+ return ;
+ }
+ if (
+ element.type === "video" ||
+ element.type === "image" ||
+ element.type === "sticker"
+ ) {
+ return ;
+ }
+ if (element.type === "effect") {
+ return ;
+ }
+ return null;
+}
export function PropertiesPanel() {
const editor = useEditor();
@@ -16,34 +44,19 @@ export function PropertiesPanel() {
elements: selectedElements,
});
+ const hasSelection = selectedElements.length > 0;
+
return (
-
- {selectedElements.length > 0 ? (
+
+ {hasSelection ? (
- {elementsWithTracks.map(({ track, element }) => {
- if (element.type === "text") {
- return (
-
-
-
- );
- }
- if (element.type === "audio") {
- return ;
- }
- if (
- element.type === "video" ||
- element.type === "image" ||
- element.type === "sticker"
- ) {
- return (
-
-
-
- );
- }
- return null;
- })}
+ {elementsWithTracks.map(({ track, element }) => (
+
+ ))}
) : (
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 18f768a9..d89d270d 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -27,7 +27,7 @@ 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() {
@@ -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/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 7ad63692..38cf953b 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -46,6 +46,7 @@ import {
Search01Icon,
Exchange01Icon,
KeyframeIcon,
+ MagicWand05Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
@@ -59,7 +60,6 @@ interface KeyframeIndicator {
time: number;
offsetPx: number;
keyframes: SelectedKeyframeRef[];
- isSelected: boolean;
}
function buildKeyframeIndicator({
@@ -69,7 +69,6 @@ function buildKeyframeIndicator({
displayedStartTime,
zoomLevel,
elementLeft,
- isKeyframeSelected,
}: {
keyframe: ElementKeyframe;
trackId: string;
@@ -77,16 +76,10 @@ function buildKeyframeIndicator({
displayedStartTime: number;
zoomLevel: number;
elementLeft: number;
- isKeyframeSelected: ({
- keyframe,
- }: {
- keyframe: SelectedKeyframeRef;
- }) => boolean;
}): {
time: number;
offsetPx: number;
keyframeRef: SelectedKeyframeRef;
- isSelected: boolean;
} {
const keyframeRef = {
trackId,
@@ -102,7 +95,6 @@ function buildKeyframeIndicator({
time: keyframe.time,
offsetPx: keyframeLeft - elementLeft,
keyframeRef,
- isSelected: isKeyframeSelected({ keyframe: keyframeRef }),
};
}
@@ -114,7 +106,6 @@ function getKeyframeIndicators({
zoomLevel,
elementLeft,
elementWidth,
- isKeyframeSelected,
}: {
keyframes: ElementKeyframe[];
trackId: string;
@@ -123,11 +114,6 @@ function getKeyframeIndicators({
zoomLevel: number;
elementLeft: number;
elementWidth: number;
- isKeyframeSelected: ({
- keyframe,
- }: {
- keyframe: SelectedKeyframeRef;
- }) => boolean;
}): KeyframeIndicator[] {
if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
return [];
@@ -142,7 +128,6 @@ function getKeyframeIndicators({
displayedStartTime,
zoomLevel,
elementLeft,
- isKeyframeSelected,
});
const existingIndicator = keyframesByTime.get(indicator.time);
if (!existingIndicator) {
@@ -150,14 +135,11 @@ function getKeyframeIndicators({
time: indicator.time,
offsetPx: indicator.offsetPx,
keyframes: [indicator.keyframeRef],
- isSelected: indicator.isSelected,
});
continue;
}
existingIndicator.keyframes.push(indicator.keyframeRef);
- existingIndicator.isSelected =
- existingIndicator.isSelected || indicator.isSelected;
}
return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
@@ -187,6 +169,7 @@ interface TimelineElementProps {
) => void;
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
dragState: ElementDragState;
+ isDropTarget?: boolean;
}
export function TimelineElement({
@@ -199,10 +182,10 @@ export function TimelineElement({
onElementMouseDown,
onElementClick,
dragState,
+ isDropTarget = false,
}: TimelineElementProps) {
const editor = useEditor();
const { selectedElements } = useElementSelection();
- const { isKeyframeSelected } = useKeyframeSelection();
const { requestRevealMedia } = useAssetsPanelStore();
const mediaAssets = editor.media.getAssets();
@@ -248,16 +231,17 @@ export function TimelineElement({
time: displayedStartTime,
zoomLevel,
});
- const keyframeIndicators = getKeyframeIndicators({
- keyframes: getElementKeyframes({ animations: element.animations }),
- trackId: track.id,
- elementId: element.id,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- elementWidth,
- isKeyframeSelected,
- });
+ 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)) {
@@ -291,8 +275,9 @@ export function TimelineElement({
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart}
+ isDropTarget={isDropTarget}
/>
-
+ {isSelected &&
}
@@ -365,6 +350,7 @@ function ElementInner({
onElementClick,
onElementMouseDown,
handleResizeStart,
+ isDropTarget = false,
}: {
element: TimelineElementType;
track: TimelineTrack;
@@ -382,14 +368,20 @@ function ElementInner({
elementId: string;
side: "left" | "right";
}) => void;
+ isDropTarget?: boolean;
}) {
+ const opacityClass =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget
+ ? "opacity-50"
+ : "";
+
return (