diff --git a/apps/web/content/changelog/0.3.0.md b/apps/web/content/changelog/0.3.0.md
index 09c7bfbd..345688fa 100644
--- a/apps/web/content/changelog/0.3.0.md
+++ b/apps/web/content/changelog/0.3.0.md
@@ -12,4 +12,71 @@ changes:
text: "Added a changelog link to the footer."
- type: fixed
text: "Media asset cards in the grid view were larger than their thumbnails, leaving empty space around each item. Cards now size to their content."
+ - type: improved
+ text: "Playback performance is dramatically better. The editor used to slow to a crawl during playback, and audio would stutter when interacting with elements mid-play. Both are fixed. Playback now runs at a consistent 60fps."
+ - type: fixed
+ text: "Project thumbnails now generate correctly even when the timeline is empty. If you set a background color or gradient with no clips, the thumbnail reflects that instead of staying blank."
+ - type: fixed
+ text: "Clicking a selected element no longer keeps the rest of the selection."
+ - type: improved
+ text: "More vertical space between timeline tracks. Selection outlines on adjacent tracks no longer overlap each other."
+ - type: fixed
+ text: "Track labels on the left side of the timeline were misaligned with the tracks on the right."
+ - type: improved
+ text: "Holding Shift or Ctrl while drag-selecting in the timeline now adds to your existing selection instead of replacing it. Matches how Shift/Ctrl+click already worked."
+ - type: improved
+ text: "Timeline track rows now highlight when they contain a selected element, making it easier to see which tracks are active at a glance."
+ - type: fixed
+ text: "Clicking in the empty space below timeline tracks now seeks the playhead."
+ - type: fixed
+ text: "Timeline vertical scroll now works anywhere in the panel, not just over the track labels. The header no longer scrolls with the tracks."
+ - type: new
+ text: "Scale now has separate width and height controls. You can stretch or squash elements independently on each axis."
+ - type: improved
+ text: "System fonts (Arial, Helvetica, Times New Roman, and others) now appear in the font picker alongside Google Fonts."
+ - type: fixed
+ text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser."
+ - type: improved
+ text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them."
+ - type: fixed
+ text: "Opening the mask or effects panel for one element, then selecting a different element, would keep the wrong panel open and hide the transform handles for the new selection. Each element now remembers its own panel state independently."
+ - type: new
+ text: "Volume control for audio and video elements, both in the properties panel and inline on timeline clips."
+ - type: new
+ text: "Speed control for video and audio clips. Includes a maintain pitch option so audio doesn't chipmunk or deepen when you change the rate."
+ - type: improved
+ text: "Audio waveforms have gotten an upgrade. The old ones were inaccurate, prone to crashing, and not very useful. The new ones are RMS-based, properly scaled, and only render what's visible so they don't slow things down."
+ - type: improved
+ text: "The timeline now opens scrolled to the bottom, so your main video track is visible right away instead of being hidden above the fold."
+ - type: fixed
+ text: "Snap guides didn't show when moving a full-canvas element to the center of the frame."
+ - type: fixed
+ text: "The rotation handle could float above the editor UI when an element was near the top of the canvas. It now clips to the preview area."
+ - type: fixed
+ text: "Images with a blur effect at 0% intensity would disappear completely when a blurred background was also active. Now 0% blur correctly renders the image unchanged."
+ - type: fixed
+ text: "Section titles in the properties panel showed the wrong text color when the section wasn't collapsible. Non-collapsible titles now use the correct muted style."
+ - type: new
+ text: "Masks. Hide or reveal parts of any video or image layer. Choose from split, rectangle, ellipse, star, heart, diamond, text, and custom masks. Adjust position, size, rotation, feather, and stroke in the properties panel, or drag the handles in the preview."
+ - type: improved
+ text: "The settings panel has been redesigned with a cleaner layout and better organization."
+ - type: new
+ text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
+ - type: new
+ text: "Canvas backgrounds are back. Set a blur, solid color, or gradient as the canvas background from the new Background tab in the settings panel."
+ - type: fixed
+ text: "Blur looked pixelated at higher intensities instead of producing a real blur. Both the canvas background blur and the blur effect are now fixed."
+ - type: new
+ text: "Custom canvas size. Instead of being locked to a handful of presets, you can now enter any width and height you want."
+ - type: new
+ text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in."
+ - type: new
+ text: "Stickers panel now has content. Browse and add logos, country flags, and shapes."
+ - type: improved
+ text: "You can now select multiple assets in the assets panel. Useful for deleting several at once."
+ - type: fixed
+ text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
+ - type: new
+ text: "You can now import transcript files to generate captions instead of running auto-transcription."
+
---
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 2f509d2a..334e486e 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -51,6 +51,10 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "api.unisvg.com",
},
+ {
+ protocol: "https",
+ hostname: "cdn.brandfetch.io",
+ },
],
},
};
diff --git a/apps/web/package.json b/apps/web/package.json
index a00c9671..9c8f229d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -16,13 +16,9 @@
"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",
+ "@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
@@ -38,15 +34,12 @@
"@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",
@@ -67,21 +60,19 @@
"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",
+ "soundtouchjs": "^0.3.0",
"tailwind-merge": "^3.5.0",
"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"
@@ -104,7 +95,6 @@
"raw-loader": "^4.0.2",
"sharp": "^0.34.5",
"tailwindcss": "^4.2.1",
- "tsx": "^4.7.1",
"typescript": "^5.8.3"
}
}
diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs
index 6ba14df9..61e36849 100644
--- a/apps/web/postcss.config.mjs
+++ b/apps/web/postcss.config.mjs
@@ -1,8 +1,7 @@
-/** @type {import('postcss-load-config').Config} */
const config = {
- plugins: {
- "@tailwindcss/postcss": {},
- },
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
};
export default config;
diff --git a/apps/web/public/flags/ad.svg b/apps/web/public/flags/ad.svg
new file mode 100644
index 00000000..199ff198
--- /dev/null
+++ b/apps/web/public/flags/ad.svg
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ae.svg b/apps/web/public/flags/ae.svg
new file mode 100644
index 00000000..651ac852
--- /dev/null
+++ b/apps/web/public/flags/ae.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/af.svg b/apps/web/public/flags/af.svg
new file mode 100644
index 00000000..4dbe4556
--- /dev/null
+++ b/apps/web/public/flags/af.svg
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ag.svg b/apps/web/public/flags/ag.svg
new file mode 100644
index 00000000..243c3d8f
--- /dev/null
+++ b/apps/web/public/flags/ag.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ai.svg b/apps/web/public/flags/ai.svg
new file mode 100644
index 00000000..9c2ea339
--- /dev/null
+++ b/apps/web/public/flags/ai.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/al.svg b/apps/web/public/flags/al.svg
new file mode 100644
index 00000000..e85d95f0
--- /dev/null
+++ b/apps/web/public/flags/al.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/am.svg b/apps/web/public/flags/am.svg
new file mode 100644
index 00000000..99fa4dc5
--- /dev/null
+++ b/apps/web/public/flags/am.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/ao.svg b/apps/web/public/flags/ao.svg
new file mode 100644
index 00000000..b73b1ec5
--- /dev/null
+++ b/apps/web/public/flags/ao.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/aq.svg b/apps/web/public/flags/aq.svg
new file mode 100644
index 00000000..c7e35362
--- /dev/null
+++ b/apps/web/public/flags/aq.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/ar.svg b/apps/web/public/flags/ar.svg
new file mode 100644
index 00000000..c753da10
--- /dev/null
+++ b/apps/web/public/flags/ar.svg
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/arab.svg b/apps/web/public/flags/arab.svg
new file mode 100644
index 00000000..9ef079f6
--- /dev/null
+++ b/apps/web/public/flags/arab.svg
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/as.svg b/apps/web/public/flags/as.svg
new file mode 100644
index 00000000..82459dec
--- /dev/null
+++ b/apps/web/public/flags/as.svg
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/asean.svg b/apps/web/public/flags/asean.svg
new file mode 100644
index 00000000..189ae020
--- /dev/null
+++ b/apps/web/public/flags/asean.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/at.svg b/apps/web/public/flags/at.svg
new file mode 100644
index 00000000..9d2775c0
--- /dev/null
+++ b/apps/web/public/flags/at.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/au.svg b/apps/web/public/flags/au.svg
new file mode 100644
index 00000000..96e80768
--- /dev/null
+++ b/apps/web/public/flags/au.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/aw.svg b/apps/web/public/flags/aw.svg
new file mode 100644
index 00000000..413b7c45
--- /dev/null
+++ b/apps/web/public/flags/aw.svg
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ax.svg b/apps/web/public/flags/ax.svg
new file mode 100644
index 00000000..0584d713
--- /dev/null
+++ b/apps/web/public/flags/ax.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/az.svg b/apps/web/public/flags/az.svg
new file mode 100644
index 00000000..35575221
--- /dev/null
+++ b/apps/web/public/flags/az.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ba.svg b/apps/web/public/flags/ba.svg
new file mode 100644
index 00000000..93bd9cf9
--- /dev/null
+++ b/apps/web/public/flags/ba.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bb.svg b/apps/web/public/flags/bb.svg
new file mode 100644
index 00000000..cecd5cc3
--- /dev/null
+++ b/apps/web/public/flags/bb.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bd.svg b/apps/web/public/flags/bd.svg
new file mode 100644
index 00000000..16b794de
--- /dev/null
+++ b/apps/web/public/flags/bd.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/be.svg b/apps/web/public/flags/be.svg
new file mode 100644
index 00000000..ac706a0b
--- /dev/null
+++ b/apps/web/public/flags/be.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bf.svg b/apps/web/public/flags/bf.svg
new file mode 100644
index 00000000..47138225
--- /dev/null
+++ b/apps/web/public/flags/bf.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bg.svg b/apps/web/public/flags/bg.svg
new file mode 100644
index 00000000..af2d0d07
--- /dev/null
+++ b/apps/web/public/flags/bg.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/bh.svg b/apps/web/public/flags/bh.svg
new file mode 100644
index 00000000..7a2ea549
--- /dev/null
+++ b/apps/web/public/flags/bh.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/bi.svg b/apps/web/public/flags/bi.svg
new file mode 100644
index 00000000..a4434a95
--- /dev/null
+++ b/apps/web/public/flags/bi.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bj.svg b/apps/web/public/flags/bj.svg
new file mode 100644
index 00000000..0846724d
--- /dev/null
+++ b/apps/web/public/flags/bj.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bl.svg b/apps/web/public/flags/bl.svg
new file mode 100644
index 00000000..f84cbbae
--- /dev/null
+++ b/apps/web/public/flags/bl.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/bm.svg b/apps/web/public/flags/bm.svg
new file mode 100644
index 00000000..f43a5ebc
--- /dev/null
+++ b/apps/web/public/flags/bm.svg
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bn.svg b/apps/web/public/flags/bn.svg
new file mode 100644
index 00000000..f544c25e
--- /dev/null
+++ b/apps/web/public/flags/bn.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bo.svg b/apps/web/public/flags/bo.svg
new file mode 100644
index 00000000..7658e3f6
--- /dev/null
+++ b/apps/web/public/flags/bo.svg
@@ -0,0 +1,673 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bq.svg b/apps/web/public/flags/bq.svg
new file mode 100644
index 00000000..0e6bc76e
--- /dev/null
+++ b/apps/web/public/flags/bq.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/br.svg b/apps/web/public/flags/br.svg
new file mode 100644
index 00000000..719a763c
--- /dev/null
+++ b/apps/web/public/flags/br.svg
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bs.svg b/apps/web/public/flags/bs.svg
new file mode 100644
index 00000000..5cc918e5
--- /dev/null
+++ b/apps/web/public/flags/bs.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bt.svg b/apps/web/public/flags/bt.svg
new file mode 100644
index 00000000..20aef3a6
--- /dev/null
+++ b/apps/web/public/flags/bt.svg
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bv.svg b/apps/web/public/flags/bv.svg
new file mode 100644
index 00000000..40e16d94
--- /dev/null
+++ b/apps/web/public/flags/bv.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bw.svg b/apps/web/public/flags/bw.svg
new file mode 100644
index 00000000..3435608d
--- /dev/null
+++ b/apps/web/public/flags/bw.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/by.svg b/apps/web/public/flags/by.svg
new file mode 100644
index 00000000..948784ff
--- /dev/null
+++ b/apps/web/public/flags/by.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/bz.svg b/apps/web/public/flags/bz.svg
new file mode 100644
index 00000000..d81b16c2
--- /dev/null
+++ b/apps/web/public/flags/bz.svg
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ca.svg b/apps/web/public/flags/ca.svg
new file mode 100644
index 00000000..c9b23b49
--- /dev/null
+++ b/apps/web/public/flags/ca.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/cc.svg b/apps/web/public/flags/cc.svg
new file mode 100644
index 00000000..a42dec66
--- /dev/null
+++ b/apps/web/public/flags/cc.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cd.svg b/apps/web/public/flags/cd.svg
new file mode 100644
index 00000000..b9cf5289
--- /dev/null
+++ b/apps/web/public/flags/cd.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/cefta.svg b/apps/web/public/flags/cefta.svg
new file mode 100644
index 00000000..f748d08a
--- /dev/null
+++ b/apps/web/public/flags/cefta.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cf.svg b/apps/web/public/flags/cf.svg
new file mode 100644
index 00000000..a6cd3670
--- /dev/null
+++ b/apps/web/public/flags/cf.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cg.svg b/apps/web/public/flags/cg.svg
new file mode 100644
index 00000000..f5a0e42d
--- /dev/null
+++ b/apps/web/public/flags/cg.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ch.svg b/apps/web/public/flags/ch.svg
new file mode 100644
index 00000000..b42d6709
--- /dev/null
+++ b/apps/web/public/flags/ch.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ci.svg b/apps/web/public/flags/ci.svg
new file mode 100644
index 00000000..e400f0c1
--- /dev/null
+++ b/apps/web/public/flags/ci.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ck.svg b/apps/web/public/flags/ck.svg
new file mode 100644
index 00000000..18e547b1
--- /dev/null
+++ b/apps/web/public/flags/ck.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cl.svg b/apps/web/public/flags/cl.svg
new file mode 100644
index 00000000..5b3c72fa
--- /dev/null
+++ b/apps/web/public/flags/cl.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cm.svg b/apps/web/public/flags/cm.svg
new file mode 100644
index 00000000..70adc8b6
--- /dev/null
+++ b/apps/web/public/flags/cm.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cn.svg b/apps/web/public/flags/cn.svg
new file mode 100644
index 00000000..10d3489a
--- /dev/null
+++ b/apps/web/public/flags/cn.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/co.svg b/apps/web/public/flags/co.svg
new file mode 100644
index 00000000..ebd0a0fb
--- /dev/null
+++ b/apps/web/public/flags/co.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cp.svg b/apps/web/public/flags/cp.svg
new file mode 100644
index 00000000..b8aa9cfd
--- /dev/null
+++ b/apps/web/public/flags/cp.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cr.svg b/apps/web/public/flags/cr.svg
new file mode 100644
index 00000000..5a409eeb
--- /dev/null
+++ b/apps/web/public/flags/cr.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cu.svg b/apps/web/public/flags/cu.svg
new file mode 100644
index 00000000..053c9ee3
--- /dev/null
+++ b/apps/web/public/flags/cu.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cv.svg b/apps/web/public/flags/cv.svg
new file mode 100644
index 00000000..aec89949
--- /dev/null
+++ b/apps/web/public/flags/cv.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cw.svg b/apps/web/public/flags/cw.svg
new file mode 100644
index 00000000..bb0ece22
--- /dev/null
+++ b/apps/web/public/flags/cw.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cx.svg b/apps/web/public/flags/cx.svg
new file mode 100644
index 00000000..3a83c23f
--- /dev/null
+++ b/apps/web/public/flags/cx.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cy.svg b/apps/web/public/flags/cy.svg
new file mode 100644
index 00000000..ee4b0c78
--- /dev/null
+++ b/apps/web/public/flags/cy.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/cz.svg b/apps/web/public/flags/cz.svg
new file mode 100644
index 00000000..7913de38
--- /dev/null
+++ b/apps/web/public/flags/cz.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/de.svg b/apps/web/public/flags/de.svg
new file mode 100644
index 00000000..71aa2d2c
--- /dev/null
+++ b/apps/web/public/flags/de.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/dg.svg b/apps/web/public/flags/dg.svg
new file mode 100644
index 00000000..dfee2bb9
--- /dev/null
+++ b/apps/web/public/flags/dg.svg
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/dj.svg b/apps/web/public/flags/dj.svg
new file mode 100644
index 00000000..9b00a820
--- /dev/null
+++ b/apps/web/public/flags/dj.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/dk.svg b/apps/web/public/flags/dk.svg
new file mode 100644
index 00000000..563277f8
--- /dev/null
+++ b/apps/web/public/flags/dk.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/dm.svg b/apps/web/public/flags/dm.svg
new file mode 100644
index 00000000..5aa9cea5
--- /dev/null
+++ b/apps/web/public/flags/dm.svg
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/do.svg b/apps/web/public/flags/do.svg
new file mode 100644
index 00000000..6de2b268
--- /dev/null
+++ b/apps/web/public/flags/do.svg
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/dz.svg b/apps/web/public/flags/dz.svg
new file mode 100644
index 00000000..5ff29a74
--- /dev/null
+++ b/apps/web/public/flags/dz.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/eac.svg b/apps/web/public/flags/eac.svg
new file mode 100644
index 00000000..59d02d21
--- /dev/null
+++ b/apps/web/public/flags/eac.svg
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ec.svg b/apps/web/public/flags/ec.svg
new file mode 100644
index 00000000..88c50bf6
--- /dev/null
+++ b/apps/web/public/flags/ec.svg
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ee.svg b/apps/web/public/flags/ee.svg
new file mode 100644
index 00000000..8b98c2c4
--- /dev/null
+++ b/apps/web/public/flags/ee.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/eg.svg b/apps/web/public/flags/eg.svg
new file mode 100644
index 00000000..88e32b3a
--- /dev/null
+++ b/apps/web/public/flags/eg.svg
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/eh.svg b/apps/web/public/flags/eh.svg
new file mode 100644
index 00000000..6aec7288
--- /dev/null
+++ b/apps/web/public/flags/eh.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/er.svg b/apps/web/public/flags/er.svg
new file mode 100644
index 00000000..48a13b47
--- /dev/null
+++ b/apps/web/public/flags/er.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/es-ct.svg b/apps/web/public/flags/es-ct.svg
new file mode 100644
index 00000000..4d859114
--- /dev/null
+++ b/apps/web/public/flags/es-ct.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/es-ga.svg b/apps/web/public/flags/es-ga.svg
new file mode 100644
index 00000000..573ca45d
--- /dev/null
+++ b/apps/web/public/flags/es-ga.svg
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/es-pv.svg b/apps/web/public/flags/es-pv.svg
new file mode 100644
index 00000000..63c19f41
--- /dev/null
+++ b/apps/web/public/flags/es-pv.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/es.svg b/apps/web/public/flags/es.svg
new file mode 100644
index 00000000..a296ebf7
--- /dev/null
+++ b/apps/web/public/flags/es.svg
@@ -0,0 +1,544 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/et.svg b/apps/web/public/flags/et.svg
new file mode 100644
index 00000000..3f99be48
--- /dev/null
+++ b/apps/web/public/flags/et.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/eu.svg b/apps/web/public/flags/eu.svg
new file mode 100644
index 00000000..b0874c1e
--- /dev/null
+++ b/apps/web/public/flags/eu.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/fi.svg b/apps/web/public/flags/fi.svg
new file mode 100644
index 00000000..470be2d0
--- /dev/null
+++ b/apps/web/public/flags/fi.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/fj.svg b/apps/web/public/flags/fj.svg
new file mode 100644
index 00000000..332ae61d
--- /dev/null
+++ b/apps/web/public/flags/fj.svg
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/fk.svg b/apps/web/public/flags/fk.svg
new file mode 100644
index 00000000..a0dace83
--- /dev/null
+++ b/apps/web/public/flags/fk.svg
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/fm.svg b/apps/web/public/flags/fm.svg
new file mode 100644
index 00000000..c1b7c977
--- /dev/null
+++ b/apps/web/public/flags/fm.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/fo.svg b/apps/web/public/flags/fo.svg
new file mode 100644
index 00000000..f802d285
--- /dev/null
+++ b/apps/web/public/flags/fo.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/fr.svg b/apps/web/public/flags/fr.svg
new file mode 100644
index 00000000..4110e59e
--- /dev/null
+++ b/apps/web/public/flags/fr.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/ga.svg b/apps/web/public/flags/ga.svg
new file mode 100644
index 00000000..76edab42
--- /dev/null
+++ b/apps/web/public/flags/ga.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gb-eng.svg b/apps/web/public/flags/gb-eng.svg
new file mode 100644
index 00000000..12e3b67d
--- /dev/null
+++ b/apps/web/public/flags/gb-eng.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/gb-nir.svg b/apps/web/public/flags/gb-nir.svg
new file mode 100644
index 00000000..e22190aa
--- /dev/null
+++ b/apps/web/public/flags/gb-nir.svg
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gb-sct.svg b/apps/web/public/flags/gb-sct.svg
new file mode 100644
index 00000000..f50cd322
--- /dev/null
+++ b/apps/web/public/flags/gb-sct.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/gb-wls.svg b/apps/web/public/flags/gb-wls.svg
new file mode 100644
index 00000000..d7f57912
--- /dev/null
+++ b/apps/web/public/flags/gb-wls.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gb.svg b/apps/web/public/flags/gb.svg
new file mode 100644
index 00000000..79913831
--- /dev/null
+++ b/apps/web/public/flags/gb.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gd.svg b/apps/web/public/flags/gd.svg
new file mode 100644
index 00000000..b3d250db
--- /dev/null
+++ b/apps/web/public/flags/gd.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ge.svg b/apps/web/public/flags/ge.svg
new file mode 100644
index 00000000..ab08a9ab
--- /dev/null
+++ b/apps/web/public/flags/ge.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gf.svg b/apps/web/public/flags/gf.svg
new file mode 100644
index 00000000..f8fe94c6
--- /dev/null
+++ b/apps/web/public/flags/gf.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/gg.svg b/apps/web/public/flags/gg.svg
new file mode 100644
index 00000000..f8216c8b
--- /dev/null
+++ b/apps/web/public/flags/gg.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gh.svg b/apps/web/public/flags/gh.svg
new file mode 100644
index 00000000..5c3e3e69
--- /dev/null
+++ b/apps/web/public/flags/gh.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gi.svg b/apps/web/public/flags/gi.svg
new file mode 100644
index 00000000..a5d7570d
--- /dev/null
+++ b/apps/web/public/flags/gi.svg
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gl.svg b/apps/web/public/flags/gl.svg
new file mode 100644
index 00000000..eb5a52e9
--- /dev/null
+++ b/apps/web/public/flags/gl.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/gm.svg b/apps/web/public/flags/gm.svg
new file mode 100644
index 00000000..8fe9d669
--- /dev/null
+++ b/apps/web/public/flags/gm.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gn.svg b/apps/web/public/flags/gn.svg
new file mode 100644
index 00000000..40d6ad4f
--- /dev/null
+++ b/apps/web/public/flags/gn.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gp.svg b/apps/web/public/flags/gp.svg
new file mode 100644
index 00000000..ee55c4bc
--- /dev/null
+++ b/apps/web/public/flags/gp.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/gq.svg b/apps/web/public/flags/gq.svg
new file mode 100644
index 00000000..64c8eb22
--- /dev/null
+++ b/apps/web/public/flags/gq.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gr.svg b/apps/web/public/flags/gr.svg
new file mode 100644
index 00000000..599741ee
--- /dev/null
+++ b/apps/web/public/flags/gr.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gs.svg b/apps/web/public/flags/gs.svg
new file mode 100644
index 00000000..29db9b94
--- /dev/null
+++ b/apps/web/public/flags/gs.svg
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gt.svg b/apps/web/public/flags/gt.svg
new file mode 100644
index 00000000..7df9df57
--- /dev/null
+++ b/apps/web/public/flags/gt.svg
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gu.svg b/apps/web/public/flags/gu.svg
new file mode 100644
index 00000000..3b95219a
--- /dev/null
+++ b/apps/web/public/flags/gu.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gw.svg b/apps/web/public/flags/gw.svg
new file mode 100644
index 00000000..d470bac9
--- /dev/null
+++ b/apps/web/public/flags/gw.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/gy.svg b/apps/web/public/flags/gy.svg
new file mode 100644
index 00000000..569fb562
--- /dev/null
+++ b/apps/web/public/flags/gy.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/hk.svg b/apps/web/public/flags/hk.svg
new file mode 100644
index 00000000..4fd55bc1
--- /dev/null
+++ b/apps/web/public/flags/hk.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/hm.svg b/apps/web/public/flags/hm.svg
new file mode 100644
index 00000000..815c4820
--- /dev/null
+++ b/apps/web/public/flags/hm.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/hn.svg b/apps/web/public/flags/hn.svg
new file mode 100644
index 00000000..11fde67d
--- /dev/null
+++ b/apps/web/public/flags/hn.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/hr.svg b/apps/web/public/flags/hr.svg
new file mode 100644
index 00000000..dde825c3
--- /dev/null
+++ b/apps/web/public/flags/hr.svg
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ht.svg b/apps/web/public/flags/ht.svg
new file mode 100644
index 00000000..8e8efc46
--- /dev/null
+++ b/apps/web/public/flags/ht.svg
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/hu.svg b/apps/web/public/flags/hu.svg
new file mode 100644
index 00000000..baddf7f5
--- /dev/null
+++ b/apps/web/public/flags/hu.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ic.svg b/apps/web/public/flags/ic.svg
new file mode 100644
index 00000000..81e6ee2e
--- /dev/null
+++ b/apps/web/public/flags/ic.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/id.svg b/apps/web/public/flags/id.svg
new file mode 100644
index 00000000..3b7c8fcf
--- /dev/null
+++ b/apps/web/public/flags/id.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/ie.svg b/apps/web/public/flags/ie.svg
new file mode 100644
index 00000000..049be14d
--- /dev/null
+++ b/apps/web/public/flags/ie.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/il.svg b/apps/web/public/flags/il.svg
new file mode 100644
index 00000000..f43be7e8
--- /dev/null
+++ b/apps/web/public/flags/il.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/im.svg b/apps/web/public/flags/im.svg
new file mode 100644
index 00000000..fe6a59a1
--- /dev/null
+++ b/apps/web/public/flags/im.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/in.svg b/apps/web/public/flags/in.svg
new file mode 100644
index 00000000..bc47d749
--- /dev/null
+++ b/apps/web/public/flags/in.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/io.svg b/apps/web/public/flags/io.svg
new file mode 100644
index 00000000..3058f7df
--- /dev/null
+++ b/apps/web/public/flags/io.svg
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/iq.svg b/apps/web/public/flags/iq.svg
new file mode 100644
index 00000000..80445147
--- /dev/null
+++ b/apps/web/public/flags/iq.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ir.svg b/apps/web/public/flags/ir.svg
new file mode 100644
index 00000000..8c6d5162
--- /dev/null
+++ b/apps/web/public/flags/ir.svg
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/is.svg b/apps/web/public/flags/is.svg
new file mode 100644
index 00000000..a6588afa
--- /dev/null
+++ b/apps/web/public/flags/is.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/it.svg b/apps/web/public/flags/it.svg
new file mode 100644
index 00000000..20a8bfdc
--- /dev/null
+++ b/apps/web/public/flags/it.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/je.svg b/apps/web/public/flags/je.svg
new file mode 100644
index 00000000..70a8754e
--- /dev/null
+++ b/apps/web/public/flags/je.svg
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/jm.svg b/apps/web/public/flags/jm.svg
new file mode 100644
index 00000000..269df038
--- /dev/null
+++ b/apps/web/public/flags/jm.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/jo.svg b/apps/web/public/flags/jo.svg
new file mode 100644
index 00000000..d6f927d4
--- /dev/null
+++ b/apps/web/public/flags/jo.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/jp.svg b/apps/web/public/flags/jp.svg
new file mode 100644
index 00000000..cc1c181c
--- /dev/null
+++ b/apps/web/public/flags/jp.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ke.svg b/apps/web/public/flags/ke.svg
new file mode 100644
index 00000000..3a67ca3c
--- /dev/null
+++ b/apps/web/public/flags/ke.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/kg.svg b/apps/web/public/flags/kg.svg
new file mode 100644
index 00000000..e26db953
--- /dev/null
+++ b/apps/web/public/flags/kg.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/kh.svg b/apps/web/public/flags/kh.svg
new file mode 100644
index 00000000..a7d52f2c
--- /dev/null
+++ b/apps/web/public/flags/kh.svg
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ki.svg b/apps/web/public/flags/ki.svg
new file mode 100644
index 00000000..fda03f37
--- /dev/null
+++ b/apps/web/public/flags/ki.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/km.svg b/apps/web/public/flags/km.svg
new file mode 100644
index 00000000..414d65e4
--- /dev/null
+++ b/apps/web/public/flags/km.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/kn.svg b/apps/web/public/flags/kn.svg
new file mode 100644
index 00000000..47fe64d6
--- /dev/null
+++ b/apps/web/public/flags/kn.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/kp.svg b/apps/web/public/flags/kp.svg
new file mode 100644
index 00000000..ad1b713f
--- /dev/null
+++ b/apps/web/public/flags/kp.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/kr.svg b/apps/web/public/flags/kr.svg
new file mode 100644
index 00000000..6947eab2
--- /dev/null
+++ b/apps/web/public/flags/kr.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/kw.svg b/apps/web/public/flags/kw.svg
new file mode 100644
index 00000000..3dd89e99
--- /dev/null
+++ b/apps/web/public/flags/kw.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ky.svg b/apps/web/public/flags/ky.svg
new file mode 100644
index 00000000..aeaa7e08
--- /dev/null
+++ b/apps/web/public/flags/ky.svg
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/kz.svg b/apps/web/public/flags/kz.svg
new file mode 100644
index 00000000..2fac45bd
--- /dev/null
+++ b/apps/web/public/flags/kz.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/la.svg b/apps/web/public/flags/la.svg
new file mode 100644
index 00000000..6aea6b72
--- /dev/null
+++ b/apps/web/public/flags/la.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/lb.svg b/apps/web/public/flags/lb.svg
new file mode 100644
index 00000000..bde2581d
--- /dev/null
+++ b/apps/web/public/flags/lb.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/lc.svg b/apps/web/public/flags/lc.svg
new file mode 100644
index 00000000..bb256541
--- /dev/null
+++ b/apps/web/public/flags/lc.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/li.svg b/apps/web/public/flags/li.svg
new file mode 100644
index 00000000..7a4d1832
--- /dev/null
+++ b/apps/web/public/flags/li.svg
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/lk.svg b/apps/web/public/flags/lk.svg
new file mode 100644
index 00000000..cbd660a5
--- /dev/null
+++ b/apps/web/public/flags/lk.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/lr.svg b/apps/web/public/flags/lr.svg
new file mode 100644
index 00000000..e482ab9d
--- /dev/null
+++ b/apps/web/public/flags/lr.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ls.svg b/apps/web/public/flags/ls.svg
new file mode 100644
index 00000000..a7c01a98
--- /dev/null
+++ b/apps/web/public/flags/ls.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/lt.svg b/apps/web/public/flags/lt.svg
new file mode 100644
index 00000000..90ec5d24
--- /dev/null
+++ b/apps/web/public/flags/lt.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/lu.svg b/apps/web/public/flags/lu.svg
new file mode 100644
index 00000000..cc122068
--- /dev/null
+++ b/apps/web/public/flags/lu.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/lv.svg b/apps/web/public/flags/lv.svg
new file mode 100644
index 00000000..6a9e75ec
--- /dev/null
+++ b/apps/web/public/flags/lv.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ly.svg b/apps/web/public/flags/ly.svg
new file mode 100644
index 00000000..1eaa51e4
--- /dev/null
+++ b/apps/web/public/flags/ly.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ma.svg b/apps/web/public/flags/ma.svg
new file mode 100644
index 00000000..7ce56eff
--- /dev/null
+++ b/apps/web/public/flags/ma.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/mc.svg b/apps/web/public/flags/mc.svg
new file mode 100644
index 00000000..9cb6c9e8
--- /dev/null
+++ b/apps/web/public/flags/mc.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/md.svg b/apps/web/public/flags/md.svg
new file mode 100644
index 00000000..e9ba506a
--- /dev/null
+++ b/apps/web/public/flags/md.svg
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/me.svg b/apps/web/public/flags/me.svg
new file mode 100644
index 00000000..297888c7
--- /dev/null
+++ b/apps/web/public/flags/me.svg
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mf.svg b/apps/web/public/flags/mf.svg
new file mode 100644
index 00000000..6305edc1
--- /dev/null
+++ b/apps/web/public/flags/mf.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/mg.svg b/apps/web/public/flags/mg.svg
new file mode 100644
index 00000000..5fa2d244
--- /dev/null
+++ b/apps/web/public/flags/mg.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mh.svg b/apps/web/public/flags/mh.svg
new file mode 100644
index 00000000..7b9f4907
--- /dev/null
+++ b/apps/web/public/flags/mh.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mk.svg b/apps/web/public/flags/mk.svg
new file mode 100644
index 00000000..4f5cae77
--- /dev/null
+++ b/apps/web/public/flags/mk.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/ml.svg b/apps/web/public/flags/ml.svg
new file mode 100644
index 00000000..6f6b7169
--- /dev/null
+++ b/apps/web/public/flags/ml.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mm.svg b/apps/web/public/flags/mm.svg
new file mode 100644
index 00000000..42b4dee2
--- /dev/null
+++ b/apps/web/public/flags/mm.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mn.svg b/apps/web/public/flags/mn.svg
new file mode 100644
index 00000000..6a38a71f
--- /dev/null
+++ b/apps/web/public/flags/mn.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mo.svg b/apps/web/public/flags/mo.svg
new file mode 100644
index 00000000..f638b6cb
--- /dev/null
+++ b/apps/web/public/flags/mo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mp.svg b/apps/web/public/flags/mp.svg
new file mode 100644
index 00000000..26bfa221
--- /dev/null
+++ b/apps/web/public/flags/mp.svg
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mq.svg b/apps/web/public/flags/mq.svg
new file mode 100644
index 00000000..b221951e
--- /dev/null
+++ b/apps/web/public/flags/mq.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/mr.svg b/apps/web/public/flags/mr.svg
new file mode 100644
index 00000000..d859972c
--- /dev/null
+++ b/apps/web/public/flags/mr.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ms.svg b/apps/web/public/flags/ms.svg
new file mode 100644
index 00000000..4367505b
--- /dev/null
+++ b/apps/web/public/flags/ms.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mt.svg b/apps/web/public/flags/mt.svg
new file mode 100644
index 00000000..5d5d7c80
--- /dev/null
+++ b/apps/web/public/flags/mt.svg
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mu.svg b/apps/web/public/flags/mu.svg
new file mode 100644
index 00000000..82d7a3be
--- /dev/null
+++ b/apps/web/public/flags/mu.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mv.svg b/apps/web/public/flags/mv.svg
new file mode 100644
index 00000000..10450f98
--- /dev/null
+++ b/apps/web/public/flags/mv.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mw.svg b/apps/web/public/flags/mw.svg
new file mode 100644
index 00000000..137ff878
--- /dev/null
+++ b/apps/web/public/flags/mw.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mx.svg b/apps/web/public/flags/mx.svg
new file mode 100644
index 00000000..e3ec2bc5
--- /dev/null
+++ b/apps/web/public/flags/mx.svg
@@ -0,0 +1,382 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/my.svg b/apps/web/public/flags/my.svg
new file mode 100644
index 00000000..115f864d
--- /dev/null
+++ b/apps/web/public/flags/my.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/mz.svg b/apps/web/public/flags/mz.svg
new file mode 100644
index 00000000..0f94c3a1
--- /dev/null
+++ b/apps/web/public/flags/mz.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/na.svg b/apps/web/public/flags/na.svg
new file mode 100644
index 00000000..35b9f783
--- /dev/null
+++ b/apps/web/public/flags/na.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/nc.svg b/apps/web/public/flags/nc.svg
new file mode 100644
index 00000000..fa155516
--- /dev/null
+++ b/apps/web/public/flags/nc.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ne.svg b/apps/web/public/flags/ne.svg
new file mode 100644
index 00000000..39a82b82
--- /dev/null
+++ b/apps/web/public/flags/ne.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/nf.svg b/apps/web/public/flags/nf.svg
new file mode 100644
index 00000000..fd61b25c
--- /dev/null
+++ b/apps/web/public/flags/nf.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ng.svg b/apps/web/public/flags/ng.svg
new file mode 100644
index 00000000..81eb35f7
--- /dev/null
+++ b/apps/web/public/flags/ng.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ni.svg b/apps/web/public/flags/ni.svg
new file mode 100644
index 00000000..e4861f5a
--- /dev/null
+++ b/apps/web/public/flags/ni.svg
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/nl.svg b/apps/web/public/flags/nl.svg
new file mode 100644
index 00000000..e90f5b03
--- /dev/null
+++ b/apps/web/public/flags/nl.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/no.svg b/apps/web/public/flags/no.svg
new file mode 100644
index 00000000..a5f2a152
--- /dev/null
+++ b/apps/web/public/flags/no.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/np.svg b/apps/web/public/flags/np.svg
new file mode 100644
index 00000000..62428568
--- /dev/null
+++ b/apps/web/public/flags/np.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/nr.svg b/apps/web/public/flags/nr.svg
new file mode 100644
index 00000000..ff394c41
--- /dev/null
+++ b/apps/web/public/flags/nr.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/nu.svg b/apps/web/public/flags/nu.svg
new file mode 100644
index 00000000..4067baff
--- /dev/null
+++ b/apps/web/public/flags/nu.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/nz.svg b/apps/web/public/flags/nz.svg
new file mode 100644
index 00000000..935d8a74
--- /dev/null
+++ b/apps/web/public/flags/nz.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/om.svg b/apps/web/public/flags/om.svg
new file mode 100644
index 00000000..4f1461a0
--- /dev/null
+++ b/apps/web/public/flags/om.svg
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pa.svg b/apps/web/public/flags/pa.svg
new file mode 100644
index 00000000..8dc03bc6
--- /dev/null
+++ b/apps/web/public/flags/pa.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pc.svg b/apps/web/public/flags/pc.svg
new file mode 100644
index 00000000..5202d6d6
--- /dev/null
+++ b/apps/web/public/flags/pc.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pe.svg b/apps/web/public/flags/pe.svg
new file mode 100644
index 00000000..33e6cfd4
--- /dev/null
+++ b/apps/web/public/flags/pe.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/pf.svg b/apps/web/public/flags/pf.svg
new file mode 100644
index 00000000..bea0354d
--- /dev/null
+++ b/apps/web/public/flags/pf.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pg.svg b/apps/web/public/flags/pg.svg
new file mode 100644
index 00000000..7b7e77aa
--- /dev/null
+++ b/apps/web/public/flags/pg.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ph.svg b/apps/web/public/flags/ph.svg
new file mode 100644
index 00000000..b910e24c
--- /dev/null
+++ b/apps/web/public/flags/ph.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pk.svg b/apps/web/public/flags/pk.svg
new file mode 100644
index 00000000..4ddc19f8
--- /dev/null
+++ b/apps/web/public/flags/pk.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pl.svg b/apps/web/public/flags/pl.svg
new file mode 100644
index 00000000..0fa51452
--- /dev/null
+++ b/apps/web/public/flags/pl.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pm.svg b/apps/web/public/flags/pm.svg
new file mode 100644
index 00000000..19a9330a
--- /dev/null
+++ b/apps/web/public/flags/pm.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/pn.svg b/apps/web/public/flags/pn.svg
new file mode 100644
index 00000000..209ea71a
--- /dev/null
+++ b/apps/web/public/flags/pn.svg
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pr.svg b/apps/web/public/flags/pr.svg
new file mode 100644
index 00000000..ec51831d
--- /dev/null
+++ b/apps/web/public/flags/pr.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ps.svg b/apps/web/public/flags/ps.svg
new file mode 100644
index 00000000..362d4359
--- /dev/null
+++ b/apps/web/public/flags/ps.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pt.svg b/apps/web/public/flags/pt.svg
new file mode 100644
index 00000000..2767cd4e
--- /dev/null
+++ b/apps/web/public/flags/pt.svg
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/pw.svg b/apps/web/public/flags/pw.svg
new file mode 100644
index 00000000..9f89c5f1
--- /dev/null
+++ b/apps/web/public/flags/pw.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/py.svg b/apps/web/public/flags/py.svg
new file mode 100644
index 00000000..abccd879
--- /dev/null
+++ b/apps/web/public/flags/py.svg
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/qa.svg b/apps/web/public/flags/qa.svg
new file mode 100644
index 00000000..901f3fa7
--- /dev/null
+++ b/apps/web/public/flags/qa.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/re.svg b/apps/web/public/flags/re.svg
new file mode 100644
index 00000000..64e788e0
--- /dev/null
+++ b/apps/web/public/flags/re.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/ro.svg b/apps/web/public/flags/ro.svg
new file mode 100644
index 00000000..fda0f7be
--- /dev/null
+++ b/apps/web/public/flags/ro.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/rs.svg b/apps/web/public/flags/rs.svg
new file mode 100644
index 00000000..6d4f74d7
--- /dev/null
+++ b/apps/web/public/flags/rs.svg
@@ -0,0 +1,292 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ru.svg b/apps/web/public/flags/ru.svg
new file mode 100644
index 00000000..cf243011
--- /dev/null
+++ b/apps/web/public/flags/ru.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/rw.svg b/apps/web/public/flags/rw.svg
new file mode 100644
index 00000000..06e26ae4
--- /dev/null
+++ b/apps/web/public/flags/rw.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sa.svg b/apps/web/public/flags/sa.svg
new file mode 100644
index 00000000..596cf48b
--- /dev/null
+++ b/apps/web/public/flags/sa.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sb.svg b/apps/web/public/flags/sb.svg
new file mode 100644
index 00000000..6066f94c
--- /dev/null
+++ b/apps/web/public/flags/sb.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sc.svg b/apps/web/public/flags/sc.svg
new file mode 100644
index 00000000..9a46b369
--- /dev/null
+++ b/apps/web/public/flags/sc.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sd.svg b/apps/web/public/flags/sd.svg
new file mode 100644
index 00000000..12818b41
--- /dev/null
+++ b/apps/web/public/flags/sd.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/se.svg b/apps/web/public/flags/se.svg
new file mode 100644
index 00000000..8ba745ac
--- /dev/null
+++ b/apps/web/public/flags/se.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/sg.svg b/apps/web/public/flags/sg.svg
new file mode 100644
index 00000000..c4dd4ac9
--- /dev/null
+++ b/apps/web/public/flags/sg.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sh-ac.svg b/apps/web/public/flags/sh-ac.svg
new file mode 100644
index 00000000..c43b301e
--- /dev/null
+++ b/apps/web/public/flags/sh-ac.svg
@@ -0,0 +1,689 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sh-hl.svg b/apps/web/public/flags/sh-hl.svg
new file mode 100644
index 00000000..2150bf60
--- /dev/null
+++ b/apps/web/public/flags/sh-hl.svg
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sh-ta.svg b/apps/web/public/flags/sh-ta.svg
new file mode 100644
index 00000000..ba390631
--- /dev/null
+++ b/apps/web/public/flags/sh-ta.svg
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sh.svg b/apps/web/public/flags/sh.svg
new file mode 100644
index 00000000..7aba0aec
--- /dev/null
+++ b/apps/web/public/flags/sh.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/si.svg b/apps/web/public/flags/si.svg
new file mode 100644
index 00000000..1bbdd94f
--- /dev/null
+++ b/apps/web/public/flags/si.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sj.svg b/apps/web/public/flags/sj.svg
new file mode 100644
index 00000000..bb2799ce
--- /dev/null
+++ b/apps/web/public/flags/sj.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sk.svg b/apps/web/public/flags/sk.svg
new file mode 100644
index 00000000..676018e6
--- /dev/null
+++ b/apps/web/public/flags/sk.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sl.svg b/apps/web/public/flags/sl.svg
new file mode 100644
index 00000000..a07baf75
--- /dev/null
+++ b/apps/web/public/flags/sl.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sm.svg b/apps/web/public/flags/sm.svg
new file mode 100644
index 00000000..e41d2f77
--- /dev/null
+++ b/apps/web/public/flags/sm.svg
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sn.svg b/apps/web/public/flags/sn.svg
new file mode 100644
index 00000000..7c0673d6
--- /dev/null
+++ b/apps/web/public/flags/sn.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/so.svg b/apps/web/public/flags/so.svg
new file mode 100644
index 00000000..a581ac63
--- /dev/null
+++ b/apps/web/public/flags/so.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sr.svg b/apps/web/public/flags/sr.svg
new file mode 100644
index 00000000..5e71c400
--- /dev/null
+++ b/apps/web/public/flags/sr.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ss.svg b/apps/web/public/flags/ss.svg
new file mode 100644
index 00000000..b257aa0b
--- /dev/null
+++ b/apps/web/public/flags/ss.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/st.svg b/apps/web/public/flags/st.svg
new file mode 100644
index 00000000..1294bcb7
--- /dev/null
+++ b/apps/web/public/flags/st.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sv.svg b/apps/web/public/flags/sv.svg
new file mode 100644
index 00000000..cbc674a6
--- /dev/null
+++ b/apps/web/public/flags/sv.svg
@@ -0,0 +1,593 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sx.svg b/apps/web/public/flags/sx.svg
new file mode 100644
index 00000000..ac785617
--- /dev/null
+++ b/apps/web/public/flags/sx.svg
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sy.svg b/apps/web/public/flags/sy.svg
new file mode 100644
index 00000000..97c05cfc
--- /dev/null
+++ b/apps/web/public/flags/sy.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/sz.svg b/apps/web/public/flags/sz.svg
new file mode 100644
index 00000000..eb538e44
--- /dev/null
+++ b/apps/web/public/flags/sz.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tc.svg b/apps/web/public/flags/tc.svg
new file mode 100644
index 00000000..12589710
--- /dev/null
+++ b/apps/web/public/flags/tc.svg
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/td.svg b/apps/web/public/flags/td.svg
new file mode 100644
index 00000000..fa3bd927
--- /dev/null
+++ b/apps/web/public/flags/td.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tf.svg b/apps/web/public/flags/tf.svg
new file mode 100644
index 00000000..fba23356
--- /dev/null
+++ b/apps/web/public/flags/tf.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tg.svg b/apps/web/public/flags/tg.svg
new file mode 100644
index 00000000..9d6ea6c5
--- /dev/null
+++ b/apps/web/public/flags/tg.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/th.svg b/apps/web/public/flags/th.svg
new file mode 100644
index 00000000..1e93a61e
--- /dev/null
+++ b/apps/web/public/flags/th.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tj.svg b/apps/web/public/flags/tj.svg
new file mode 100644
index 00000000..f8c9a037
--- /dev/null
+++ b/apps/web/public/flags/tj.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tk.svg b/apps/web/public/flags/tk.svg
new file mode 100644
index 00000000..05d3e86c
--- /dev/null
+++ b/apps/web/public/flags/tk.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/tl.svg b/apps/web/public/flags/tl.svg
new file mode 100644
index 00000000..3d0701a2
--- /dev/null
+++ b/apps/web/public/flags/tl.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tm.svg b/apps/web/public/flags/tm.svg
new file mode 100644
index 00000000..4154ed76
--- /dev/null
+++ b/apps/web/public/flags/tm.svg
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tn.svg b/apps/web/public/flags/tn.svg
new file mode 100644
index 00000000..5735c198
--- /dev/null
+++ b/apps/web/public/flags/tn.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/to.svg b/apps/web/public/flags/to.svg
new file mode 100644
index 00000000..d0723370
--- /dev/null
+++ b/apps/web/public/flags/to.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tr.svg b/apps/web/public/flags/tr.svg
new file mode 100644
index 00000000..b96da21f
--- /dev/null
+++ b/apps/web/public/flags/tr.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tt.svg b/apps/web/public/flags/tt.svg
new file mode 100644
index 00000000..bc24938c
--- /dev/null
+++ b/apps/web/public/flags/tt.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/tv.svg b/apps/web/public/flags/tv.svg
new file mode 100644
index 00000000..675210ec
--- /dev/null
+++ b/apps/web/public/flags/tv.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tw.svg b/apps/web/public/flags/tw.svg
new file mode 100644
index 00000000..57fd98b4
--- /dev/null
+++ b/apps/web/public/flags/tw.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/tz.svg b/apps/web/public/flags/tz.svg
new file mode 100644
index 00000000..a2cfbca4
--- /dev/null
+++ b/apps/web/public/flags/tz.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ua.svg b/apps/web/public/flags/ua.svg
new file mode 100644
index 00000000..a339eb1b
--- /dev/null
+++ b/apps/web/public/flags/ua.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ug.svg b/apps/web/public/flags/ug.svg
new file mode 100644
index 00000000..520eee5c
--- /dev/null
+++ b/apps/web/public/flags/ug.svg
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/um.svg b/apps/web/public/flags/um.svg
new file mode 100644
index 00000000..9e9eddaa
--- /dev/null
+++ b/apps/web/public/flags/um.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/un.svg b/apps/web/public/flags/un.svg
new file mode 100644
index 00000000..632bbb4a
--- /dev/null
+++ b/apps/web/public/flags/un.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/us.svg b/apps/web/public/flags/us.svg
new file mode 100644
index 00000000..9cfd0c92
--- /dev/null
+++ b/apps/web/public/flags/us.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/uy.svg b/apps/web/public/flags/uy.svg
new file mode 100644
index 00000000..62c36f8e
--- /dev/null
+++ b/apps/web/public/flags/uy.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/uz.svg b/apps/web/public/flags/uz.svg
new file mode 100644
index 00000000..0ccca1b1
--- /dev/null
+++ b/apps/web/public/flags/uz.svg
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/va.svg b/apps/web/public/flags/va.svg
new file mode 100644
index 00000000..3e297d63
--- /dev/null
+++ b/apps/web/public/flags/va.svg
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/vc.svg b/apps/web/public/flags/vc.svg
new file mode 100644
index 00000000..f26c2d8d
--- /dev/null
+++ b/apps/web/public/flags/vc.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/ve.svg b/apps/web/public/flags/ve.svg
new file mode 100644
index 00000000..314e7f5f
--- /dev/null
+++ b/apps/web/public/flags/ve.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/vg.svg b/apps/web/public/flags/vg.svg
new file mode 100644
index 00000000..ac900888
--- /dev/null
+++ b/apps/web/public/flags/vg.svg
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/vi.svg b/apps/web/public/flags/vi.svg
new file mode 100644
index 00000000..d88d68f9
--- /dev/null
+++ b/apps/web/public/flags/vi.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/vn.svg b/apps/web/public/flags/vn.svg
new file mode 100644
index 00000000..7e4bac8f
--- /dev/null
+++ b/apps/web/public/flags/vn.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/vu.svg b/apps/web/public/flags/vu.svg
new file mode 100644
index 00000000..326d29e9
--- /dev/null
+++ b/apps/web/public/flags/vu.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/wf.svg b/apps/web/public/flags/wf.svg
new file mode 100644
index 00000000..054c57df
--- /dev/null
+++ b/apps/web/public/flags/wf.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/ws.svg b/apps/web/public/flags/ws.svg
new file mode 100644
index 00000000..0e758a7a
--- /dev/null
+++ b/apps/web/public/flags/ws.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/xk.svg b/apps/web/public/flags/xk.svg
new file mode 100644
index 00000000..0e8958d8
--- /dev/null
+++ b/apps/web/public/flags/xk.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/xx.svg b/apps/web/public/flags/xx.svg
new file mode 100644
index 00000000..9333be36
--- /dev/null
+++ b/apps/web/public/flags/xx.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/flags/ye.svg b/apps/web/public/flags/ye.svg
new file mode 100644
index 00000000..1c9e6d63
--- /dev/null
+++ b/apps/web/public/flags/ye.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/yt.svg b/apps/web/public/flags/yt.svg
new file mode 100644
index 00000000..e7776b30
--- /dev/null
+++ b/apps/web/public/flags/yt.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/web/public/flags/za.svg b/apps/web/public/flags/za.svg
new file mode 100644
index 00000000..d563adb9
--- /dev/null
+++ b/apps/web/public/flags/za.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/zm.svg b/apps/web/public/flags/zm.svg
new file mode 100644
index 00000000..360f37aa
--- /dev/null
+++ b/apps/web/public/flags/zm.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/flags/zw.svg b/apps/web/public/flags/zw.svg
new file mode 100644
index 00000000..93aac4f6
--- /dev/null
+++ b/apps/web/public/flags/zw.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/shapes/circle.svg b/apps/web/public/shapes/circle.svg
index adb837cf..2de141cd 100644
--- a/apps/web/public/shapes/circle.svg
+++ b/apps/web/public/shapes/circle.svg
@@ -1,3 +1,3 @@
-
+
diff --git a/apps/web/public/shapes/diamond.svg b/apps/web/public/shapes/diamond.svg
index 4a039b5c..a0a31aa8 100644
--- a/apps/web/public/shapes/diamond.svg
+++ b/apps/web/public/shapes/diamond.svg
@@ -1,3 +1,3 @@
-
+
diff --git a/apps/web/public/shapes/hexagon.svg b/apps/web/public/shapes/hexagon.svg
index e1eb087e..e50cd91f 100644
--- a/apps/web/public/shapes/hexagon.svg
+++ b/apps/web/public/shapes/hexagon.svg
@@ -1,3 +1,6 @@
-
+
diff --git a/apps/web/public/shapes/square.svg b/apps/web/public/shapes/square.svg
index ed9b790f..58a0f6ad 100644
--- a/apps/web/public/shapes/square.svg
+++ b/apps/web/public/shapes/square.svg
@@ -1,3 +1,3 @@
-
+
diff --git a/apps/web/public/shapes/star.svg b/apps/web/public/shapes/star.svg
index c49e0c53..f646764b 100644
--- a/apps/web/public/shapes/star.svg
+++ b/apps/web/public/shapes/star.svg
@@ -1,3 +1,6 @@
-
+
diff --git a/apps/web/public/shapes/triangle.svg b/apps/web/public/shapes/triangle.svg
index a1bd6b0c..9c1c5a41 100644
--- a/apps/web/public/shapes/triangle.svg
+++ b/apps/web/public/shapes/triangle.svg
@@ -1,3 +1,3 @@
-
+
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
index e1afc3a8..90afd919 100644
--- a/apps/web/src/app/blog/[slug]/page.tsx
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -5,7 +5,7 @@ import { BasePage } from "@/app/base-page";
import Prose from "@/components/ui/prose";
import { Separator } from "@/components/ui/separator";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
-import type { Author, Post } from "@/types/blog";
+import type { Author, Post } from "@/lib/blog/types";
type PageProps = {
params: Promise<{ slug: string }>;
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx
index 257a8f23..df0b195d 100644
--- a/apps/web/src/app/blog/page.tsx
+++ b/apps/web/src/app/blog/page.tsx
@@ -3,7 +3,7 @@ import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
-import type { Post } from "@/types/blog";
+import type { Post } from "@/lib/blog/types";
export const metadata: Metadata = {
title: "Blog - OpenCut",
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 6d3e984f..21219427 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -8,111 +8,111 @@
@plugin "tailwindcss-animate";
:root {
- --background: hsl(0, 0%, 100%);
- --foreground: hsl(0 0% 11%);
- --card: hsl(0, 0%, 100%);
- --card-foreground: hsl(0 0% 11%);
- --popover: hsl(0, 0%, 100%);
- --popover-hover: hsl(0, 0%, 96%);
- --popover-foreground: hsl(0 0% 2%);
- --primary: #009dff;
- --primary-foreground: hsl(0, 0%, 100%);
- --secondary: hsl(204, 100%, 97%);
- --secondary-border: hsl(204, 100%, 94%);
- --secondary-foreground: hsl(200, 98%, 39%);
- --muted: hsl(0 0% 85.1%);
- --muted-foreground: hsl(0 0% 50%);
- --accent: hsl(0, 0%, 96%);
- --accent-foreground: hsl(0 0% 2%);
- --destructive: hsl(0, 83%, 50%);
- --destructive-foreground: hsl(0, 0%, 100%);
- --constructive: hsl(141, 71%, 48%);
- --constructive-foreground: hsl(0, 0%, 100%);
- --border: hsl(0 0% 91%);
- --input: hsl(0, 0%, 100%);
- --ring: hsl(0, 0%, 55%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar-background: hsl(0 0% 96.1%);
- --sidebar-foreground: hsl(0 0% 2%);
- --sidebar-primary: hsl(0 0% 2%);
- --sidebar-primary-foreground: hsl(0 0% 91%);
- --sidebar-accent: hsl(0 0% 85.1%);
- --sidebar-accent-foreground: hsl(0 0% 2%);
- --sidebar-border: hsl(0 0% 85.1%);
- --sidebar-ring: hsl(0 0% 16.9%);
- --sidebar: hsl(0 0% 98%);
+ --background: hsl(0, 0%, 100%);
+ --foreground: hsl(0 0% 11%);
+ --card: hsl(0, 0%, 100%);
+ --card-foreground: hsl(0 0% 11%);
+ --popover: hsl(0, 0%, 100%);
+ --popover-hover: hsl(0, 0%, 96%);
+ --popover-foreground: hsl(0 0% 2%);
+ --primary: hsl(200, 90%, 52%);
+ --primary-foreground: hsl(0, 0%, 100%);
+ --secondary: hsl(204, 100%, 97%);
+ --secondary-border: hsl(204, 100%, 94%);
+ --secondary-foreground: hsl(200, 98%, 39%);
+ --muted: hsl(0 0% 85.1%);
+ --muted-foreground: hsl(0 0% 50%);
+ --accent: hsl(0, 0%, 96%);
+ --accent-foreground: hsl(0 0% 2%);
+ --destructive: hsl(0, 83%, 50%);
+ --destructive-foreground: hsl(0, 0%, 100%);
+ --constructive: hsl(141, 71%, 48%);
+ --constructive-foreground: hsl(0, 0%, 100%);
+ --border: hsl(0 0% 91%);
+ --input: hsl(0, 0%, 100%);
+ --ring: hsl(0, 0%, 55%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+ --sidebar-background: hsl(0 0% 96.1%);
+ --sidebar-foreground: hsl(0 0% 2%);
+ --sidebar-primary: hsl(0 0% 2%);
+ --sidebar-primary-foreground: hsl(0 0% 91%);
+ --sidebar-accent: hsl(0 0% 85.1%);
+ --sidebar-accent-foreground: hsl(0 0% 2%);
+ --sidebar-border: hsl(0 0% 85.1%);
+ --sidebar-ring: hsl(0 0% 16.9%);
+ --sidebar: hsl(0 0% 98%);
}
.panel {
- --background: hsl(216 13% 98%);
- --foreground: hsl(0 0% 13%);
- --card: hsl(0, 0%, 98%);
- --card-foreground: hsl(0 0% 13%);
- --primary-foreground: hsl(0, 0%, 98%);
- --secondary: hsl(204, 100%, 95%);
- --secondary-border: hsl(204, 100%, 92%);
- --secondary-foreground: hsl(200, 98%, 37%);
- --muted: hsl(0 0% 83.1%);
- --muted-foreground: hsl(0 0% 48%);
- --accent: hsl(0, 0%, 93%);
- --accent-foreground: hsl(0 0% 5%);
- --destructive-foreground: hsl(0, 0%, 98%);
- --constructive-foreground: hsl(0, 0%, 98%);
- --border: hsl(0 0% 89%);
- --input: hsl(0 0% 93%);
- --ring: hsl(0, 0%, 53%);
+ --background: hsl(210, 20%, 98%);
+ --foreground: hsl(0 0% 13%);
+ --card: hsl(0, 0%, 98%);
+ --card-foreground: hsl(0 0% 13%);
+ --primary-foreground: hsl(0, 0%, 98%);
+ --secondary: hsl(204, 100%, 95%);
+ --secondary-border: hsl(204, 100%, 92%);
+ --secondary-foreground: hsl(200, 98%, 37%);
+ --muted: hsl(0 0% 83.1%);
+ --muted-foreground: hsl(0 0% 48%);
+ --accent: hsl(0, 0%, 93%);
+ --accent-foreground: hsl(0 0% 5%);
+ --destructive-foreground: hsl(0, 0%, 98%);
+ --constructive-foreground: hsl(0, 0%, 98%);
+ --border: hsl(0 0% 87%);
+ --input: hsl(0 0% 93%);
+ --ring: hsl(0, 0%, 53%);
}
.dark {
- --background: hsl(0, 0%, 5%);
- --foreground: hsl(0 0% 87%);
- --card: hsl(0, 0%, 5%);
- --card-foreground: hsl(0 0% 87%);
- --popover: hsl(0, 0%, 5%);
- --popover-hover: hsl(0, 0%, 22%);
- --popover-foreground: hsl(0 0% 95%);
- --secondary: hsl(204, 100%, 12%);
- --secondary-border: hsl(204, 100%, 15%);
- --secondary-foreground: hsl(200, 98%, 61%);
- --muted: hsl(0 0% 20%);
- --accent: hsl(0, 0%, 14%);
- --accent-foreground: hsl(0 0% 95%);
- --border: hsl(0 0% 16%);
- --input: hsl(0 0% 5%);
- --ring: hsl(0, 0%, 50%);
- --sidebar-background: hsl(0 0% 8%);
- --sidebar-foreground: hsl(0 0% 95%);
- --sidebar-primary: hsl(0 0% 95%);
- --sidebar-primary-foreground: hsl(0 0% 15%);
- --sidebar-accent: hsl(0 0% 20%);
- --sidebar-accent-foreground: hsl(0 0% 95%);
- --sidebar-border: hsl(0 0% 20%);
- --sidebar-ring: hsl(0 0% 83.1%);
- --sidebar: hsl(0 0% 6%);
+ --background: hsl(0, 0%, 5%);
+ --foreground: hsl(0 0% 87%);
+ --card: hsl(0, 0%, 5%);
+ --card-foreground: hsl(0 0% 87%);
+ --popover: hsl(0, 0%, 5%);
+ --popover-hover: hsl(0, 0%, 13%);
+ --popover-foreground: hsl(0 0% 95%);
+ --secondary: hsl(204, 100%, 12%);
+ --secondary-border: hsl(204, 100%, 15%);
+ --secondary-foreground: hsl(200, 98%, 61%);
+ --muted: hsl(0 0% 20%);
+ --accent: hsl(0, 0%, 14%);
+ --accent-foreground: hsl(0 0% 95%);
+ --border: hsl(0 0% 16%);
+ --input: hsl(0 0% 5%);
+ --ring: hsl(0, 0%, 50%);
+ --sidebar-background: hsl(0 0% 8%);
+ --sidebar-foreground: hsl(0 0% 95%);
+ --sidebar-primary: hsl(0 0% 95%);
+ --sidebar-primary-foreground: hsl(0 0% 15%);
+ --sidebar-accent: hsl(0 0% 20%);
+ --sidebar-accent-foreground: hsl(0 0% 95%);
+ --sidebar-border: hsl(0 0% 20%);
+ --sidebar-ring: hsl(0 0% 83.1%);
+ --sidebar: hsl(0 0% 6%);
}
.dark .panel {
- --background: hsl(0 0% 10%);
- --foreground: hsl(0 0% 85%);
- --card: hsl(0, 0%, 10%);
- --card-foreground: hsl(0 0% 85%);
- --secondary: hsl(204, 67%, 9%);
- --secondary-border: hsl(204, 100%, 14%);
- --secondary-foreground: hsl(200, 98%, 63%);
- --muted: hsl(0 0% 22%);
- --accent: hsl(0, 0%, 15%);
- --accent-foreground: hsl(0 0% 93%);
- --border: hsl(0 0% 18%);
- --input: hsl(0 0% 22%);
- --ring: hsl(0, 0%, 52%);
+ --background: hsl(0 0% 10%);
+ --foreground: hsl(0 0% 85%);
+ --card: hsl(0, 0%, 10%);
+ --card-foreground: hsl(0 0% 85%);
+ --secondary: hsl(204, 67%, 9%);
+ --secondary-border: hsl(204, 100%, 14%);
+ --secondary-foreground: hsl(200, 98%, 63%);
+ --muted: hsl(0 0% 22%);
+ --accent: hsl(0, 0%, 15%);
+ --accent-foreground: hsl(0 0% 93%);
+ --border: hsl(0 0% 18%);
+ --input: hsl(0 0% 22%);
+ --ring: hsl(0, 0%, 52%);
}
@layer base {
- /*
+ /*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
@@ -120,165 +120,165 @@
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
- *,
- ::after,
- ::before,
- ::backdrop,
- ::file-selector-button {
- border-color: var(--color-gray-200, currentcolor);
- }
- /* Other default base styles */
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- /* Prevent back/forward swipe */
- overscroll-behavior-x: contain;
- }
- ::selection {
- @apply bg-primary/35 selection:text-primary-foreground;
- }
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+ /* Other default base styles */
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ /* Prevent back/forward swipe */
+ overscroll-behavior-x: contain;
+ }
+ ::selection {
+ @apply bg-primary/35 selection:text-primary-foreground;
+ }
}
@theme inline {
- /* Responsive breakpoints */
- --breakpoint-xs: 30rem;
+ /* Responsive breakpoints */
+ --breakpoint-xs: 30rem;
- /* Typography */
- --font-sans: var(--font-inter), sans-serif;
+ /* Typography */
+ --font-sans: var(--font-inter), sans-serif;
- /* Font sizes */
- --text-xs: 0.72rem;
- --text-sm: 0.79rem;
- --text-base: 0.92rem;
- --text-base--line-height: calc(1.5 / 0.95);
- --text-xs--line-height: calc(1 / 0.8);
+ /* Font sizes */
+ --text-xs: 0.72rem;
+ --text-sm: 0.79rem;
+ --text-base: 0.92rem;
+ --text-base--line-height: calc(1.5 / 0.95);
+ --text-xs--line-height: calc(1 / 0.8);
- /* Border radius */
- --radius-lg: 0.82rem;
- --radius-md: 0.65rem;
- --radius-sm: 0.35rem;
+ /* Border radius */
+ --radius-lg: 0.82rem;
+ --radius-md: 0.65rem;
+ --radius-sm: 0.35rem;
- /* Palette mapped to root design tokens */
- --color-background: var(--background);
- --color-foreground: var(--foreground);
+ /* Palette mapped to root design tokens */
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
- --color-popover: var(--popover);
- --color-popover-hover: var(--popover-hover);
- --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-popover-hover: var(--popover-hover);
+ --color-popover-foreground: var(--popover-foreground);
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
- --color-secondary: var(--secondary);
- --color-secondary-border: var(--secondary-border);
- --color-secondary-foreground: var(--secondary-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-border: var(--secondary-border);
+ --color-secondary-foreground: var(--secondary-foreground);
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
- --color-destructive: var(--destructive);
- --color-destructive-foreground: var(--destructive-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
- --color-constructive: var(--constructive);
- --color-constructive-foreground: var(--constructive-foreground);
+ --color-constructive: var(--constructive);
+ --color-constructive-foreground: var(--constructive-foreground);
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
- /* Chart colors */
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
+ /* Chart colors */
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
- /* Sidebar */
- --color-sidebar: var(--sidebar-background);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
+ /* Sidebar */
+ --color-sidebar: var(--sidebar-background);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
- /* Animations */
- --animate-accordion-down: accordion-down 0.2s ease-out;
- --animate-accordion-up: accordion-up 0.2s ease-out;
+ /* Animations */
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
- @keyframes accordion-down {
- from {
- height: 0;
- }
- to {
- height: var(--radix-accordion-content-height);
- }
- }
+ @keyframes accordion-down {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+ }
- @keyframes accordion-up {
- from {
- height: var(--radix-accordion-content-height);
- }
- to {
- height: 0;
- }
- }
+ @keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+ }
}
@utility scrollbar-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
@utility scrollbar-x-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:horizontal {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:horizontal {
+ display: none;
+ }
}
@utility scrollbar-y-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:vertical {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:vertical {
+ display: none;
+ }
}
@utility scrollbar-thin {
- &::-webkit-scrollbar {
- width: 6px;
- height: 8px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- &::-webkit-scrollbar-thumb {
- background: var(--border);
- border-radius: 4px;
- }
- &::-webkit-scrollbar-thumb:hover {
- background: var(--muted-foreground);
- }
+ &::-webkit-scrollbar {
+ width: 7px;
+ height: 7px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+ }
+ &::-webkit-scrollbar-thumb:hover {
+ background: var(--muted-foreground);
+ }
}
@layer base {
- * {
- @apply border-border outline-ring/50;
- }
- body {
- @apply bg-background text-foreground;
- }
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index c2608bd9..f8e6040f 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -29,11 +29,20 @@ export default function RootLayout({
{process.env.NODE_ENV === "development" && (
-
+ <>
+
+
+ {/* code to figma */}
+ {/* */}
+ >
)}
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx
index 4b06405a..0672cfe5 100644
--- a/apps/web/src/app/privacy/page.tsx
+++ b/apps/web/src/app/privacy/page.tsx
@@ -37,7 +37,7 @@ export default function PrivacyPage() {
- Your content stays private and encrypted.
+ Your content never leaves your device.
@@ -45,22 +45,19 @@ export default function PrivacyPage() {
your files
- AI features require encrypted uploads - your content is
- encrypted before leaving your device
+ AI features like auto captions run locally in your browser
+ too - nothing is uploaded
- We only collect your email and basic profile info for your
- account
+ OpenCut does not currently require an account or login
Project data stays on your device, not our servers
- We use analytics to improve the app, but no personal video
+ We use anonymized analytics to improve the app, but no personal video
content is tracked
-
- You can delete your account anytime and all data gets removed
-
- We don't sell your data or share it with advertisers
+ You can clear local data from your browser at any time
+ We don't sell or share your data with anyone (we don't even have it)
Questions? Email us at{" "}
@@ -78,84 +75,30 @@ export default function PrivacyPage() {
How We Handle Your Content
- Basic video editing happens locally on your device. {" "}
- For standard editing features, we never upload, store, or have access
- to your video files. Your content remains completely private and under
- your control.
-
-
- AI features require secure processing: When you
- choose to use AI features like auto captions, your audio/video content
- is encrypted on your device before being uploaded to our servers for
- processing. We use zero-knowledge encryption, meaning we cannot
- decrypt or view your content.
-
-
- After AI processing is complete, the encrypted content is immediately
- deleted from our servers. Only the results (like generated captions)
- are returned to your device.
+
+ All editing and processing happens locally on your device.
+ {" "}
+ We never upload, store, or have access to your video or audio files.
+ Your content remains completely private and under your control.
+ AI-powered features like auto captions also run in your browser using
+ on-device models - no content ever leaves your device.
- Account Information
- When you create an account, we only collect:
-
- Email address (for account access)
-
- Profile information from Google OAuth (if you choose to sign in with
- Google)
-
-
+ Accounts & Authentication
- We do NOT store your projects on our servers. All
- project data, including names, thumbnails, and creation dates, is
- stored locally in your browser using IndexedDB.
+ OpenCut does not currently offer user accounts, login, or Google
+ sign-in.
- We use{" "}
-
- Better Auth
- {" "}
- for secure authentication and follow industry-standard security
- practices.
+ Because there is no account system today, we do not collect account
+ emails, profile information, or OAuth identity data.
-
-
-
- AI Features & Encryption
- When you use AI-powered features (like auto captions, content
- analysis, or enhancement tools), your content needs to be processed on
- our servers. Here's how we protect your privacy:
-
-
-
- Client-side encryption: Your content is encrypted
- on your device before upload
-
-
- Zero-knowledge processing: We cannot decrypt or
- view your original content
-
-
- Temporary processing: Encrypted content is deleted
- immediately after processing
-
-
- Opt-in only: AI features are optional - basic
- editing remains fully local
-
-
-
- Different AI features may process different types of content (audio
- for captions, video for analysis, etc.), but all follow the same
- zero-knowledge encryption approach.
+ Your projects are never stored on our servers. All project data,
+ including names, thumbnails, and creation dates, is stored locally
+ in your browser using IndexedDB.
@@ -171,13 +114,12 @@ export default function PrivacyPage() {
>
Databuddy
{" "}
- for completely anonymized and non-invasive analytics to understand how
- people use OpenCut.
+ for basic, anonymized visitor counts. We do not track clicks,
+ interactions, or how you use the editor.
- This helps us improve the editor, but we never collect personal
- information, track individual users, or store any data that could
- identify you.
+ No personal information is collected, no individual users are tracked,
+ and no data that could identify you is stored.
@@ -187,7 +129,7 @@ export default function PrivacyPage() {
Save your projects locally on your device
Remember your editor preferences and settings
- Keep you logged in across browser sessions
+ Store app state needed for the editor to work between sessions
All data stays on your device and can be cleared at any time through
@@ -199,10 +141,6 @@ export default function PrivacyPage() {
Third-Party Services
OpenCut integrates with these services:
-
- Google OAuth: For optional Google sign-in (governed
- by Google's privacy policy)
-
Vercel: For hosting and content delivery
@@ -216,8 +154,7 @@ export default function PrivacyPage() {
Your Rights
You have complete control over your data:
- Delete your account and all associated data at any time
- Export your project data
+ No account is required to use OpenCut today
Clear local storage to remove all saved projects
Contact us with any privacy concerns
@@ -280,7 +217,7 @@ export default function PrivacyPage() {
- Last updated: July 14, 2025
+ Last updated: March 15, 2026
);
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index c25d75b9..9618de27 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -7,6 +7,7 @@ import type { KeyboardEvent, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
+import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
@@ -18,7 +19,7 @@ import type {
TProjectMetadata,
TProjectSortKey,
TProjectSortOption,
-} from "@/types/project";
+} from "@/lib/project/types";
import { formatTimeCode } from "@/lib/time";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
@@ -86,6 +87,13 @@ const VIEW_MODE_OPTIONS = [
export default function ProjectsPage() {
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
const editor = useEditor();
+ const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
+
+ const isLoading = useEditor((e) => e.project.getIsLoading());
+ const isInitialized = useEditor((e) => e.project.getIsInitialized());
+ const projectsToDisplay = useEditor((e) =>
+ e.project.getFilteredAndSortedProjects({ searchQuery, sortOption }),
+ );
useEffect(() => {
if (!editor.project.getIsInitialized()) {
@@ -93,18 +101,10 @@ export default function ProjectsPage() {
}
}, [editor.project]);
- const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
- const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
- searchQuery,
- sortOption,
- });
-
- const isLoading = editor.project.getIsLoading();
- const isInitialized = editor.project.getIsInitialized();
-
return (
+
p.id)} />
diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts
index 767f08df..2c79aa0a 100644
--- a/apps/web/src/app/projects/store.ts
+++ b/apps/web/src/app/projects/store.ts
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import type { TProjectSortKey } from "@/types/project";
+import type { TProjectSortKey } from "@/lib/project/types";
export type ProjectsViewMode = "grid" | "list";
diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx
index d5d43ba7..697a89ef 100644
--- a/apps/web/src/app/terms/page.tsx
+++ b/apps/web/src/app/terms/page.tsx
@@ -41,18 +41,17 @@ export default function TermsPage() {
- Your content stays private - basic editing is local, AI features
- use encrypted uploads
+ Everything runs locally in your browser - nothing is ever
+ uploaded to our servers
- We never claim ownership of your content, even when processing
- AI features
+ We never claim ownership of your content
Free for personal and commercial use with no watermarks or
restrictions
- Don't use OpenCut for illegal activities or harassment
+ You're responsible for how you use it - don't break the law
Service provided "as is" - we can't guarantee perfect uptime
@@ -61,8 +60,7 @@ export default function TermsPage() {
needed
- You can delete your account anytime and keep using your exported
- videos
+ No account required - your exported videos are always yours
@@ -81,21 +79,17 @@ export default function TermsPage() {
Your Content, Your Rights
- You own everything you create. OpenCut processes
- basic editing locally on your device. For AI features, content is
- encrypted before upload and we cannot access your original files. We
- make no claims to ownership, licensing, or rights over your videos,
- projects, or any content you create using OpenCut.
+ You own everything you create. All editing and
+ processing happens locally on your device. We never see, store, or
+ have access to your files. We make no claims to ownership, licensing,
+ or rights over your videos, projects, or any content you create using
+ OpenCut.
- Your content remains private and under your control at all times
+ Your content never leaves your device
You retain all intellectual property rights to your content
-
- Even when using AI features, we cannot access your unencrypted
- content
-
You can export and use your content however you choose
No watermarks, no licensing restrictions from OpenCut
@@ -115,53 +109,26 @@ export default function TermsPage() {
- What we ask: Don't use OpenCut for illegal
- activities, harassment, or creating harmful content. Be respectful of
- others and follow applicable laws.
+ You're responsible for how you use OpenCut and the content you create.
+ Don't use it for anything illegal in your jurisdiction.
-
- AI Features and Data Processing
-
+ AI Features
- OpenCut offers optional AI-powered features that require server
- processing:
-
-
-
- AI features (auto captions, content analysis, etc.) are completely
- optional
-
- Your content is encrypted on your device before any upload
-
- We use zero-knowledge encryption - we cannot decrypt your content
-
- Encrypted content is deleted immediately after processing
-
- You maintain full ownership and control of your content throughout
-
-
-
- By using AI features, you consent to the temporary, encrypted
- processing of your content as described in our Privacy Policy. You can
- always choose to use only local editing features.
+ AI features like auto captions run entirely in your browser using
+ on-device models. No content is uploaded to any server. These features
+ are optional - you can use OpenCut without them.
- Account and Service
- To use certain features, you may create an account:
-
- Provide accurate information when signing up
- Keep your account secure and don't share credentials
- You're responsible for activity under your account
- You can delete your account at any time
-
+ Service
- OpenCut is provided "as is" without warranties. While we strive for
- reliability, we can't guarantee uninterrupted service.
+ OpenCut does not currently require an account. The service is provided
+ "as is" without warranties. While we strive for reliability, we can't
+ guarantee uninterrupted service.
@@ -188,24 +155,6 @@ export default function TermsPage() {
-
- Third-Party Content
-
- When using OpenCut, make sure you have the right to use any content
- you import:
-
-
- Only upload content you own or have permission to use
-
- Respect copyright, trademarks, and other intellectual property
-
-
- Don't use copyrighted music, images, or videos without permission
-
- You're responsible for any claims related to your content
-
-
-
Limitations and Liability
@@ -239,13 +188,10 @@ export default function TermsPage() {
- Termination
+ Stopping Use
You can stop using OpenCut at any time:
- Delete your account through your profile settings
Clear your browser data to remove local projects
- Your content remains yours even if you stop using OpenCut
- We may suspend accounts for violations of these terms
@@ -288,7 +234,7 @@ export default function TermsPage() {
- Last updated: July 14, 2025
+ Last updated: March 15, 2026
);
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx
index 61f23372..40390f07 100644
--- a/apps/web/src/components/editable-timecode.tsx
+++ b/apps/web/src/components/editable-timecode.tsx
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time";
-import type { TTimeCode } from "@/types/time";
+import type { TTimeCode } from "@/lib/time";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
@@ -66,16 +66,13 @@ export function EditableTimecode({
enterPressedRef.current = false;
};
- const handleKeyDown = ({
- key,
- preventDefault,
- }: React.KeyboardEvent) => {
- if (key === "Enter") {
- preventDefault();
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
enterPressedRef.current = true;
applyEdit();
- } else if (key === "Escape") {
- preventDefault();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
cancelEditing();
}
};
@@ -93,14 +90,13 @@ export function EditableTimecode({
}
};
- const handleDisplayKeyDown = ({
- key,
- preventDefault,
- }: React.KeyboardEvent) => {
+ const handleDisplayKeyDown = (
+ event: React.KeyboardEvent,
+ ) => {
if (disabled) return;
- if (key === "Enter" || key === " ") {
- preventDefault();
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
startEditing();
}
};
diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
index 7b736b23..42f99c79 100644
--- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
+++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
@@ -6,7 +6,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import type { TProjectMetadata } from "@/types/project";
+import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
import { Button } from "@/components/ui/button";
diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
index 565fe705..9a353a1c 100644
--- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
+++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
@@ -110,7 +110,7 @@ export function ShortcutsDialog({
Keyboard shortcuts
-
+
{categories.map((category) => (
diff --git a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx
new file mode 100644
index 00000000..d391928c
--- /dev/null
+++ b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { useStoragePersistence } from "@/hooks/use-storage-persistence";
+
+export function StoragePersistenceDialog() {
+ const { showDialog, onConfirm, onDismiss } = useStoragePersistence();
+
+ return (
+
!open && onDismiss()}>
+
+
+ Don't lose your projects
+
+
+
+ Your browser can automatically delete your projects when storage
+ runs low.
+
+
+ Allow OpenCut to protect them?
+
+
+
+
+ Not now
+
+ Allow
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index 2dea73e2..8dd730b5 100644
--- a/apps/web/src/components/editor/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -47,7 +47,7 @@ function ProjectDropdown() {
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const activeProject = useEditor((e) => e.project.getActive());
const handleExit = async () => {
if (isExiting) return;
@@ -136,7 +136,7 @@ function ProjectDropdown() {
-
}>
+
}>
e.project.getActive());
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef
(null);
const originalNameRef = useRef("");
@@ -219,6 +219,7 @@ function EditableProjectName() {
event.preventDefault();
if (inputRef.current) {
inputRef.current.value = originalNameRef.current;
+ inputRef.current.setSelectionRange(0, 0);
}
setIsEditing(false);
inputRef.current?.blur();
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index ddf8308d..ec39e7e6 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -14,20 +14,24 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/utils/ui";
-import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export";
+import {
+ getExportMimeType,
+ getExportFileExtension,
+ downloadBuffer,
+} from "@/lib/export";
import { Check, Copy, Download, RotateCcw } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
type ExportFormat,
type ExportQuality,
-} from "@/types/export";
+} from "@/lib/export";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
-} from "@/components/editor/panels/properties/section";
+} from "@/components/section";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
@@ -42,8 +46,8 @@ function isExportQuality(value: string): value is ExportQuality {
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const editor = useEditor();
-
- const hasProject = !!editor.project.getActiveOrNull();
+ const activeProject = useEditor((e) => e.project.getActiveOrNull());
+ const hasProject = !!activeProject;
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
if (!open) {
@@ -54,7 +58,10 @@ export function ExportButton() {
};
return (
- handlePopoverOpenChange({ open })}>
+ handlePopoverOpenChange({ open })}
+ >
void;
}) {
const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { isExporting, progress, result: exportResult } =
- editor.project.getExportState();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const exportState = useEditor((e) => e.project.getExportState());
+ const { isExporting, progress, result: exportResult } = exportState;
const [format, setFormat] = useState(
DEFAULT_EXPORT_OPTIONS.format,
);
@@ -109,10 +116,10 @@ function ExportPopover({
const result = await editor.project.export({
options: {
- format,
- quality,
- fps: activeProject.settings.fps,
- includeAudio: shouldIncludeAudio,
+ format,
+ quality,
+ fps: activeProject.settings.fps,
+ includeAudio: shouldIncludeAudio,
},
});
@@ -156,7 +163,11 @@ function ExportPopover({
{!isExporting && (
<>
-
+
Format
@@ -228,10 +239,10 @@ function ExportPopover({
- setShouldIncludeAudio(!!checked)
- }
+ checked={shouldIncludeAudio}
+ onCheckedChange={(checked) =>
+ setShouldIncludeAudio(!!checked)
+ }
/>
Include audio in export
@@ -252,15 +263,15 @@ function ExportPopover({
{isExporting && (
-
-
-
- {Math.round(progress * 100)}%
-
-
100%
+
+
+
+ {Math.round(progress * 100)}%
+
+
100%
+
+
-
-
+
-
- Take a look anyway
-
+
Take a look anyway
Roadmap
diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
index c6b0861c..9d9ae903 100644
--- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
+++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
@@ -29,7 +29,7 @@ export function MediaDragOverlay({
return (
handleClick({ event })}
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
index fae74e84..8c9acce8 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import { clearDragData, setDragData } from "@/lib/drag-data";
-import type { TimelineDragData } from "@/types/drag";
+import type { TimelineDragData } from "@/lib/timeline/drag";
import { cn } from "@/utils/ui";
export interface DraggableItemProps {
@@ -29,7 +29,6 @@ export interface DraggableItemProps {
isRounded?: boolean;
variant?: "card" | "compact";
isDraggable?: boolean;
- isHighlighted?: boolean;
}
export function DraggableItem({
@@ -46,13 +45,11 @@ export function DraggableItem({
isRounded = true,
variant = "card",
isDraggable = true,
- isHighlighted = false,
}: DraggableItemProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef(null);
const editor = useEditor();
- const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`;
const handleAddToTimeline = () => {
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
@@ -102,9 +99,8 @@ export function DraggableItem({
>
-
+
{preview}
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index 61777a7c..a4e072f1 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -49,7 +49,7 @@ export function TabBar() {
{TAB_KEYS.map((tabKey) => {
const tab = tabs[tabKey];
@@ -57,12 +57,13 @@ export function TabBar() {
setActiveTab(tabKey)}
>
@@ -102,8 +103,8 @@ function FadeOverlay({
className={cn(
"pointer-events-none absolute right-0 left-0 h-6",
direction === "top" && show
- ? "from-background top-0 bg-gradient-to-b to-transparent"
- : "from-background bottom-0 bg-gradient-to-t to-transparent",
+ ? "from-background top-0 bg-linear-to-b to-transparent"
+ : "from-background bottom-0 bg-linear-to-t to-transparent",
)}
/>
);
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 3b35c022..e17e4d4a 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -2,11 +2,11 @@
import Image from "next/image";
import { useMemo, useState } from "react";
-import { toast } from "sonner";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
import {
ContextMenu,
ContextMenuContent,
@@ -28,8 +28,15 @@ import {
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
-import { useRevealItem } from "@/hooks/use-reveal-item";
+import { invokeAction } from "@/lib/actions";
import { processMediaAssets } from "@/lib/media/processing";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import {
+ SelectableItem,
+ SelectableSurface,
+ useSelection,
+ useSelectionScope,
+} from "@/lib/selection";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import {
type MediaSortKey,
@@ -37,7 +44,8 @@ import {
type MediaViewMode,
useAssetsPanelStore,
} from "@/stores/assets-panel-store";
-import type { MediaAsset } from "@/types/assets";
+import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
@@ -52,8 +60,8 @@ import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
export function MediaView() {
const editor = useEditor();
- const mediaFiles = editor.media.getAssets();
- const activeProject = editor.project.getActive();
+ const mediaFiles = useEditor((e) => e.media.getAssets());
+ const activeProject = useEditor((e) => e.project.getActive());
const {
mediaViewMode,
@@ -64,15 +72,11 @@ export function MediaView() {
mediaSortOrder,
setMediaSort,
} = useAssetsPanelStore();
- const { highlightedId, registerElement } = useRevealItem(
- highlightMediaId,
- clearHighlight,
- );
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
- const processFiles = async ({ files }: { files: FileList }) => {
+ const processFiles = async ({ files }: { files: File[] }) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
@@ -82,20 +86,28 @@ export function MediaView() {
setIsProcessing(true);
setProgress(0);
try {
- const processedAssets = await processMediaAssets({
- files,
- onProgress: (progress: { progress: number }) =>
- setProgress(progress.progress),
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({
+ files,
+ onProgress: (progress: { progress: number }) =>
+ setProgress(progress.progress),
+ });
+ for (const asset of processedAssets) {
+ await editor.media.addMediaAsset({
+ projectId: activeProject.metadata.id,
+ asset,
+ });
+ }
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
});
- for (const asset of processedAssets) {
- await editor.media.addMediaAsset({
- projectId: activeProject.metadata.id,
- asset,
- });
- }
} catch (error) {
console.error("Error processing files:", error);
- toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
@@ -109,23 +121,18 @@ export function MediaView() {
onFilesSelected: (files) => processFiles({ files }),
});
- const handleRemove = async ({
+ const handleRemove = ({
event,
- id,
+ ids,
}: {
event: React.MouseEvent;
- id: string;
+ ids: string[];
}) => {
event.stopPropagation();
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- await editor.media.removeMediaAsset({
+ invokeAction("remove-media-assets", {
projectId: activeProject.metadata.id,
- id,
+ assetIds: ids,
});
};
@@ -172,6 +179,9 @@ export function MediaView() {
return filtered;
}, [mediaFiles, mediaSortBy, mediaSortOrder]);
+ const orderedMediaIds = useMemo(() => {
+ return filteredMediaItems.map((item) => item.id);
+ }, [filteredMediaItems]);
return (
<>
@@ -191,6 +201,7 @@ export function MediaView() {
/>
}
className={cn(isDragOver && "bg-accent/30")}
+ contentClassName="h-full"
{...dragProps}
>
{isDragOver || filteredMediaItems.length === 0 ? (
@@ -201,29 +212,38 @@ export function MediaView() {
onClick={openFilePicker}
/>
) : (
-
+
+
+
+
)}
>
);
}
+function MediaScopeRegistrar() {
+ useSelectionScope();
+ return null;
+}
+
function MediaAssetDraggable({
item,
preview,
- isHighlighted,
variant,
isRounded,
}: {
item: MediaAsset;
preview: React.ReactNode;
- isHighlighted: boolean;
variant: "card" | "compact";
isRounded?: boolean;
}) {
@@ -261,7 +281,7 @@ function MediaAssetDraggable({
mediaType: item.type,
name: item.name,
...(item.type !== "audio" && {
- targetElementTypes: ["video", "image"] as const,
+ targetElementTypes: [...MASKABLE_ELEMENT_TYPES],
}),
}}
shouldShowPlusOnDrag={false}
@@ -270,7 +290,6 @@ function MediaAssetDraggable({
}
variant={variant}
isRounded={isRounded}
- isHighlighted={isHighlighted}
/>
);
}
@@ -282,18 +301,31 @@ function MediaItemWithContextMenu({
}: {
item: MediaAsset;
children: React.ReactNode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onRemove: ({
+ event,
+ ids,
+ }: {
+ event: React.MouseEvent;
+ ids: string[];
+ }) => void;
}) {
+ const { isSelected, selectedIds } = useSelection();
+ const idsToDelete = isSelected(item.id) ? selectedIds : [item.id];
+ const deleteLabel =
+ idsToDelete.length > 1 ? `Delete ${idsToDelete.length} items` : "Delete";
+
return (
- {children}
+ {children}
Export clips
onRemove({ event, id: item.id })}
+ onClick={(event: React.MouseEvent) =>
+ onRemove({ event, ids: idsToDelete })
+ }
>
- Delete
+ {deleteLabel}
@@ -304,27 +336,29 @@ function MediaItemList({
items,
mode,
onRemove,
- highlightedId,
- registerElement,
}: {
items: MediaAsset[];
mode: MediaViewMode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
- highlightedId: string | null;
- registerElement: (id: string, element: HTMLElement | null) => void;
+ onRemove: ({
+ event,
+ ids,
+ }: {
+ event: React.MouseEvent;
+ ids: string[];
+ }) => void;
}) {
const isGrid = mode === "grid";
return (
{items.map((item) => (
-
registerElement(item.id, element)}>
-
+
+
-
-
+
+
))}
);
}
-export function formatDuration({ duration }: { duration: number }) {
+function formatDuration({ duration }: { duration: number }) {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
@@ -406,7 +439,7 @@ function MediaPreview({
if (item.type === "image") {
return (
-
+
{
actions?: React.ReactNode;
children: React.ReactNode;
contentClassName?: string;
+ scrollClassName?: string;
hideHeader?: boolean;
ref?: React.Ref;
onScroll?: React.UIEventHandler;
@@ -17,6 +18,7 @@ export function PanelView({
children,
className,
contentClassName,
+ scrollClassName,
hideHeader = false,
ref,
onScroll,
@@ -30,15 +32,18 @@ export function PanelView({
{...rest}
>
{!hideHeader && (
-
-
{title}
+
+ {title && (
+ {title}
+ )}
{actions}
)}
- new InsertElementCommand({
- placement: { mode: "explicit", trackId: addTrackCommand.getTrackId() },
- element: {
- ...DEFAULT_TEXT_ELEMENT,
- name: `Caption ${i + 1}`,
- content: caption.text,
- duration: caption.duration,
- startTime: caption.startTime,
- fontSize: 65,
- fontWeight: "bold",
- },
- })
- );
+ const addTrackCommand = new AddTrackCommand("text", 0);
+ const insertCommands = captionChunks.map(
+ (caption, i) =>
+ new InsertElementCommand({
+ placement: {
+ mode: "explicit",
+ trackId: addTrackCommand.getTrackId(),
+ },
+ element: {
+ ...DEFAULTS.text.element,
+ name: `Caption ${i + 1}`,
+ content: caption.text,
+ duration: caption.duration,
+ startTime: caption.startTime,
+ fontSize: 65,
+ fontWeight: "bold",
+ },
+ }),
+ );
- editor.command.execute({
- command: new BatchCommand([addTrackCommand, ...insertCommands]),
- });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCommand, ...insertCommands]),
+ });
} catch (error) {
console.error("Transcription failed:", error);
setError(
@@ -117,9 +121,13 @@ export function Captions() {
};
return (
-
-
-
+
+
+
- }
{isProcessing ? processingStep : "Generate transcript"}
-
-
+
+
);
}
diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx
index c9b8b2dd..74128df6 100644
--- a/apps/web/src/components/editor/panels/assets/views/effects.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx
@@ -1,19 +1,16 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
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 { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
import { useEditor } from "@/hooks/use-editor";
import { buildEffectElement } from "@/lib/timeline/element-utils";
-import type { EffectDefinition } from "@/types/effects";
+import type { EffectDefinition } from "@/lib/effects/types";
export function EffectsView() {
- const effects = getAllEffects();
+ const effects = effectsRegistry.getAll();
return (
@@ -50,7 +47,7 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) {
};
render();
- return onPreviewImageReady({ callback: render });
+ return effectPreviewService.onPreviewImageReady({ callback: render });
}, [effectType]);
return ;
diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
deleted file mode 100644
index 457c08a9..00000000
--- a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
+++ /dev/null
@@ -1,332 +0,0 @@
-// @ts-nocheck
-
-"use client";
-
-import Image from "next/image";
-import { memo, useCallback, useMemo } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {
- BLUR_INTENSITY_PRESETS,
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_COLOR,
- FPS_PRESETS,
-} from "@/constants/project-constants";
-import { patternCraftGradients } from "@/data/colors/pattern-craft";
-import { colors } from "@/data/colors/solid";
-import { syntaxUIGradients } from "@/data/colors/syntax-ui";
-import { useEditor } from "@/hooks/use-editor";
-import { useEditorStore } from "@/stores/editor-store";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import { cn } from "@/utils/ui";
-import {
- PropertyGroup,
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "@/components/editor/panels/properties/section";
-
-export function SettingsView() {
- return (
-
-
-
- );
-}
-
-function ProjectInfoView() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { canvasPresets } = useEditorStore();
-
- const findPresetIndexByAspectRatio = ({
- presets,
- targetAspectRatio,
- }: {
- presets: Array<{ width: number; height: number }>;
- targetAspectRatio: string;
- }) => {
- for (let index = 0; index < presets.length; index++) {
- const preset = presets[index];
- const presetAspectRatio = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- if (presetAspectRatio === targetAspectRatio) {
- return index;
- }
- }
- return -1;
- };
-
- const currentCanvasSize = activeProject.settings.canvasSize;
- const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
- const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
- const presetIndex = findPresetIndexByAspectRatio({
- presets: canvasPresets,
- targetAspectRatio: currentAspectRatio,
- });
- const originalPresetValue = "original";
- const selectedPresetValue =
- presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
-
- const handleAspectRatioChange = ({ value }: { value: string }) => {
- if (value === originalPresetValue) {
- const canvasSize = originalCanvasSize ?? currentCanvasSize;
- editor.project.updateSettings({
- settings: { canvasSize },
- });
- return;
- }
- const index = parseInt(value, 10);
- const preset = canvasPresets[index];
- if (preset) {
- editor.project.updateSettings({ settings: { canvasSize: preset } });
- }
- };
-
- const handleFpsChange = (value: string) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- };
-
- return (
-
-
- Name
- {activeProject.metadata.name}
-
-
-
- Aspect ratio
-
- handleAspectRatioChange({ value })}
- >
-
-
-
-
- Original
- {canvasPresets.map((preset, index) => {
- const label = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- return (
-
- {label}
-
- );
- })}
-
-
-
-
-
-
- Frame rate
-
-
-
-
-
-
- {FPS_PRESETS.map((preset) => (
-
- {preset.label}
-
- ))}
-
-
-
-
-
- );
-}
-
-const BlurPreview = memo(
- ({
- blur,
- isSelected,
- onSelect,
- }: {
- blur: { label: string; value: number };
- isSelected: boolean;
- onSelect: () => void;
- }) => (
-
-
-
-
- {blur.label}
-
-
-
- ),
-);
-
-BlurPreview.displayName = "BlurPreview";
-
-const BackgroundPreviews = memo(
- ({
- backgrounds,
- currentBackgroundColor,
- isColorBackground,
- handleColorSelect,
- useBackgroundColor = false,
- }: {
- backgrounds: string[];
- currentBackgroundColor: string;
- isColorBackground: boolean;
- handleColorSelect: ({ bg }: { bg: string }) => void;
- useBackgroundColor?: boolean;
- }) => {
- return useMemo(
- () =>
- backgrounds.map((bg, index) => (
- handleColorSelect({ bg })}
- type="button"
- aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
- />
- )),
- [
- backgrounds,
- isColorBackground,
- currentBackgroundColor,
- handleColorSelect,
- useBackgroundColor,
- ],
- );
- },
-);
-
-BackgroundPreviews.displayName = "BackgroundPreviews";
-
-function BackgroundView() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
-
- const handleBlurSelect = useCallback(
- async ({ blurIntensity }: { blurIntensity: number }) => {
- await editor.project.updateSettings({
- settings: { background: { type: "blur", blurIntensity } },
- });
- },
- [editor.project],
- );
-
- const handleColorSelect = useCallback(
- async ({ color }: { color: string }) => {
- await editor.project.updateSettings({
- settings: { background: { type: "color", color } },
- });
- },
- [editor.project],
- );
-
- const currentBlurIntensity =
- activeProject.settings.background.type === "blur"
- ? activeProject.settings.background.blurIntensity
- : DEFAULT_BLUR_INTENSITY;
-
- const currentBackgroundColor =
- activeProject.settings.background.type === "color"
- ? activeProject.settings.background.color
- : DEFAULT_COLOR;
-
- const isBlurBackground = activeProject.settings.background.type === "blur";
- const isColorBackground = activeProject.settings.background.type === "color";
-
- const blurPreviews = useMemo(
- () =>
- blurLevels.map((blur) => (
- handleBlurSelect({ blurIntensity: blur.value })}
- />
- )),
- [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
- );
-
- const backgroundSections = [
- { title: "Colors", backgrounds: colors, useBackgroundColor: true },
- { title: "Pattern craft", backgrounds: patternCraftGradients },
- { title: "Syntax UI", backgrounds: syntaxUIGradients },
- ];
-
- return (
-
-
- {blurPreviews}
-
-
- {backgroundSections.map((section) => (
-
-
- handleColorSelect({ color: bg })}
- useBackgroundColor={section.useBackgroundColor}
- />
-
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx
deleted file mode 100644
index d46e9d1f..00000000
--- a/apps/web/src/components/editor/panels/assets/views/settings.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-"use client";
-
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FPS_PRESETS } from "@/constants/project-constants";
-import { useEditor } from "@/hooks/use-editor";
-import { useEditorStore } from "@/stores/editor-store";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
-} from "@/components/editor/panels/properties/section";
-import { Label } from "@/components/ui/label";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-
-const ORIGINAL_PRESET_VALUE = "original";
-
-export function findPresetIndexByAspectRatio({
- presets,
- targetAspectRatio,
-}: {
- presets: Array<{ width: number; height: number }>;
- targetAspectRatio: string;
-}) {
- for (let index = 0; index < presets.length; index++) {
- const preset = presets[index];
- const presetAspectRatio = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- if (presetAspectRatio === targetAspectRatio) {
- return index;
- }
- }
- return -1;
-}
-
-export function SettingsView() {
- return (
-
-
-
-
-
-
-
- }
- >
- Background
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function ProjectInfoContent() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { canvasPresets } = useEditorStore();
-
- const currentCanvasSize = activeProject.settings.canvasSize;
- const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
- const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
- const presetIndex = findPresetIndexByAspectRatio({
- presets: canvasPresets,
- targetAspectRatio: currentAspectRatio,
- });
- const selectedPresetValue =
- presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE;
-
- const handleAspectRatioChange = ({ value }: { value: string }) => {
- if (value === ORIGINAL_PRESET_VALUE) {
- const canvasSize = originalCanvasSize ?? currentCanvasSize;
- editor.project.updateSettings({
- settings: { canvasSize },
- });
- return;
- }
- const index = parseInt(value, 10);
- const preset = canvasPresets[index];
- if (preset) {
- editor.project.updateSettings({ settings: { canvasSize: preset } });
- }
- };
-
- const handleFpsChange = ({ value }: { value: string }) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- };
-
- return (
-
-
- Name
-
- {activeProject.metadata.name}
-
-
-
- Aspect ratio
- handleAspectRatioChange({ value })}
- >
-
-
-
-
- Original
- {canvasPresets.map((preset, index) => {
- const label = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- return (
-
- {label}
-
- );
- })}
-
-
-
-
- Frame rate
- handleFpsChange({ value })}
- >
-
-
-
-
- {FPS_PRESETS.map((preset) => (
-
- {preset.label}
-
- ))}
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
new file mode 100644
index 00000000..b3ef1eb1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+} from "@/constants/project-constants";
+import { patternCraftGradients } from "@/data/colors/pattern-craft";
+import { colors } from "@/data/colors/solid";
+import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { useEditor } from "@/hooks/use-editor";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
+import type { TCanvasSize } from "@/lib/project/types";
+import { cn } from "@/utils/ui";
+
+const BlurPreview = memo(
+ ({
+ blur,
+ canvasSize,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ canvasSize: TCanvasSize;
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => {
+ const canvasRef = useRef(null);
+ const { width, height } = canvasSize;
+
+ useEffect(() => {
+ const renderPreview = () => {
+ if (!canvasRef.current) return;
+
+ effectPreviewService.renderPreview({
+ effectType: "blur",
+ params: { intensity: blur.value },
+ targetCanvas: canvasRef.current,
+ uniformDimensions: { width, height },
+ });
+ };
+
+ renderPreview();
+ return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
+ }, [blur.value, width, height]);
+
+ return (
+
+
+
+
+ {blur.label}
+
+
+
+ );
+ },
+);
+
+BlurPreview.displayName = "BlurPreview";
+
+const BackgroundPreviews = memo(
+ ({
+ backgrounds,
+ currentBackgroundColor,
+ isColorBackground,
+ onSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ onSelect: (bg: string) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg) => (
+ onSelect(bg)}
+ type="button"
+ aria-label={`Select background ${bg}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ onSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+const COLOR_SECTIONS = [
+ { title: "Colors", backgrounds: colors, useBackgroundColor: true },
+ { title: "Pattern craft", backgrounds: patternCraftGradients },
+ { title: "Syntax UI", backgrounds: syntaxUIGradients },
+] as const;
+
+export function BackgroundContent() {
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+
+ const handleBlurSelect = useCallback(
+ async (blurIntensity: number) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async (color: string) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const currentBlurIntensity = isBlurBackground
+ ? (activeProject.settings.background as { blurIntensity: number })
+ .blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor = isColorBackground
+ ? (activeProject.settings.background as { color: string }).color
+ : DEFAULT_COLOR;
+ const canvasSize = activeProject.settings.canvasSize;
+
+ const blurPreviews = useMemo(
+ () =>
+ BLUR_INTENSITY_PRESETS.map((blur) => (
+ handleBlurSelect(blur.value)}
+ />
+ )),
+ [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ return (
+
+
+
+ Blur
+
+
+ {blurPreviews}
+
+
+ {COLOR_SECTIONS.map((section) => (
+
+
+ {section.title}
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
new file mode 100644
index 00000000..e0229ea9
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -0,0 +1,369 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FPS_PRESETS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { BackgroundContent } from "./background";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { useEditorStore } from "@/stores/editor-store";
+import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Tick02Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { formatNumberForDisplay } from "@/utils/math";
+import { OcSquarePlusIcon } from "@opencut/ui/icons";
+import type { TCanvasSize } from "@/lib/project/types";
+
+type SettingsView = "project-info" | "background";
+
+const PRESET_LABELS: Record = {
+ "1:1": "1:1",
+ "16:9": "16:9",
+ "9:16": "9:16",
+ "4:3": "4:3",
+};
+
+function areCanvasSizesEqual({
+ left,
+ right,
+}: {
+ left: TCanvasSize;
+ right: TCanvasSize;
+}) {
+ return left.width === right.width && left.height === right.height;
+}
+
+function formatCanvasDimension({ value }: { value: number }) {
+ return formatNumberForDisplay({ value, maxFractionDigits: 0 });
+}
+
+function parseCanvasDimension({ input }: { input: string }): number | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) return null;
+
+ const rounded = Math.round(parsed);
+ return rounded > 0 ? rounded : null;
+}
+
+function useCanvasDimensionDraft({
+ value,
+ onCommit,
+}: {
+ value: number;
+ onCommit: (value: number) => void;
+}) {
+ const pendingValueRef = useRef(value);
+ const syncedValueRef = useRef(value);
+
+ if (syncedValueRef.current !== value) {
+ syncedValueRef.current = value;
+ pendingValueRef.current = value;
+ }
+
+ return usePropertyDraft({
+ displayValue: formatCanvasDimension({ value }),
+ parse: (input) => parseCanvasDimension({ input }),
+ onPreview: (nextValue) => {
+ pendingValueRef.current = nextValue;
+ },
+ onCommit: () => {
+ if (pendingValueRef.current !== value) {
+ onCommit(pendingValueRef.current);
+ }
+ },
+ });
+}
+
+export function SettingsView() {
+ const [view, setView] = useState("project-info");
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const { canvasPresets } = useEditorStore();
+ const currentCanvasSize = activeProject.settings.canvasSize;
+ const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
+ const lastCustomCanvasSize =
+ activeProject.settings.lastCustomCanvasSize ?? null;
+
+ const presetItems = canvasPresets.map((preset, index) => {
+ const ratio = dimensionToAspectRatio(preset);
+ return {
+ id: index.toString(),
+ label: PRESET_LABELS[ratio] ?? ratio,
+ ratio,
+ canvasSize: preset,
+ };
+ });
+
+ const selectedPresetId = canvasSizeMode === "preset"
+ ? (presetItems.find((preset) =>
+ areCanvasSizesEqual({
+ left: preset.canvasSize,
+ right: currentCanvasSize,
+ }),
+ )?.id ?? null)
+ : null;
+
+ const updateCustomCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateLastCustomCanvasSize =
+ lastCustomCanvasSize === null ||
+ !areCanvasSizesEqual({
+ left: canvasSize,
+ right: lastCustomCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
+
+ if (
+ !shouldUpdateCanvasSize &&
+ !shouldUpdateLastCustomCanvasSize &&
+ !shouldUpdateCanvasSizeMode
+ ) {
+ return;
+ }
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "custom" as const }
+ : {}),
+ lastCustomCanvasSize: canvasSize,
+ },
+ });
+ };
+
+ const selectPresetCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
+
+ if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "preset" as const }
+ : {}),
+ },
+ });
+ };
+
+ const selectCustomCanvasSize = () => {
+ updateCustomCanvasSize({
+ canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
+ });
+ };
+
+ const widthDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.width,
+ onCommit: (width) =>
+ updateCustomCanvasSize({
+ canvasSize: { width, height: currentCanvasSize.height },
+ }),
+ });
+
+ const heightDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.height,
+ onCommit: (height) =>
+ updateCustomCanvasSize({
+ canvasSize: { width: currentCanvasSize.width, height },
+ }),
+ });
+
+ const isCustomSelected = canvasSizeMode === "custom";
+
+ return (
+ setView(v as SettingsView)}>
+
+ Project info
+ Background
+
+
+ }
+ >
+ {view === "project-info" && (
+
+
+
+ Name
+
+ {activeProject.metadata.name}
+
+
+
+
+
+ Frame rate
+ {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ }}
+ >
+
+
+
+
+ {FPS_PRESETS.map((preset) => (
+
+ {preset.label}
+
+ ))}
+
+
+
+
+
+
+ Aspect ratio
+
+
+ {presetItems.map((preset) => (
+ }
+ isSelected={selectedPresetId === preset.id}
+ onClick={() => {
+ selectPresetCanvasSize({
+ canvasSize: preset.canvasSize,
+ });
+ }}
+ />
+ ))}
+
+
}
+ isSelected={isCustomSelected}
+ onClick={selectCustomCanvasSize}
+ uiOptions={
+
+
+
+
+ }
+ />
+
+
+
+
+ )}
+ {view === "background" && }
+
+ );
+}
+
+function AspectRatioItem({
+ label,
+ previewIcon,
+ isSelected,
+ onClick,
+ uiOptions,
+}: {
+ label: string;
+ previewIcon: React.ReactNode;
+ isSelected: boolean;
+ onClick: () => void;
+ uiOptions?: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {previewIcon}
+
+
{label}
+
+
+ {isSelected && }
+
+
+ {uiOptions && isSelected && (
+ {uiOptions}
+ )}
+
+ );
+}
+
+function AspectRatioPreview({ ratio }: { ratio?: string }) {
+ if (!ratio) return null;
+
+ const [w, h] = ratio.split(":").map(Number);
+ const maxSize = 16;
+ const width = w >= h ? maxSize : (w / h) * maxSize;
+ const height = h >= w ? maxSize : (h / w) * maxSize;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
index b1c57c3d..4fb43ac2 100644
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -24,7 +24,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { useSoundSearch } from "@/hooks/use-sound-search";
import { useSoundsStore } from "@/stores/sounds-store";
-import type { SavedSound, SoundEffect } from "@/types/sounds";
+import type { SavedSound, SoundEffect } from "@/lib/sounds/types";
import { cn } from "@/utils/ui";
import {
FavouriteIcon,
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
index 671effec..af489630 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -2,81 +2,110 @@
import Image from "next/image";
import type { CSSProperties } from "react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { toast } from "sonner";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { useEditor } from "@/hooks/use-editor";
+import { resolveStickerIntrinsicSize } from "@/lib/stickers";
import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import {
- resolveStickerId,
- type StickerItem as StickerData,
+ buildGraphicElement,
+ buildStickerElement,
+} from "@/lib/timeline/element-utils";
+import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
+import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
+import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
+import type { TimelineDragData } from "@/lib/timeline/drag";
+import type {
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem as StickerData,
} from "@/lib/stickers";
import { useStickersStore } from "@/stores/stickers-store";
import { cn } from "@/utils/ui";
import {
HappyIcon,
- ClockIcon,
- MultiplicationSignIcon,
- Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { Spinner } from "@/components/ui/spinner";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { OcSlidersVerticalIcon } from "@opencut/ui/icons";
-import type { StickerCategory } from "@/types/stickers";
-import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
-import { parseStickerId } from "@/lib/stickers/sticker-id";
export function StickersView() {
- const { selectedCategory, setSelectedCategory } = useStickersStore();
+ const {
+ browseContent,
+ browseStickers,
+ searchQuery,
+ searchStickers,
+ selectedCategory,
+ setSearchQuery,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ useEffect(() => {
+ if (viewMode === "browse" && !browseContent) {
+ void browseStickers();
+ }
+ }, [browseContent, browseStickers, viewMode]);
return (
-
-
- setSelectedCategory({ category: value })
- }
+
+
+ {
+ setSearchQuery({ query: e.target.value });
+ void searchStickers({ query: e.target.value });
+ }}
+ showClearIcon
+ onClear={() => {
+ setSearchQuery({ query: "" });
+ void searchStickers({ query: "" });
+ }}
+ className="w-full"
+ containerClassName="w-full"
+ />
+
+
+
+
+
-
-
-
-
- {Object.entries(STICKER_CATEGORIES).map(([category, label]) => (
-
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
+ const isActive = key === selectedCategory;
+ return (
+
+ setSelectedCategory({
+ category: key as StickerCategory,
+ })
+ }
+ className={cn(
+ "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
+ isActive && "text-primary border-primary",
+ )}
+ >
{label}
-
- ))}
-
-
-
-
-
-
-
-
-
-
+
+ );
+ })}
+
- }
- >
-
-
+
+
+
+
+
);
}
@@ -92,10 +121,10 @@ function StickerGrid({
"--sticker-max"?: string;
} = {
gridTemplateColumns: shouldCapSize
- ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
- : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
- "--sticker-min": "96px",
- ...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
+ ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
+ : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
+ "--sticker-min": "80px",
+ ...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
};
return (
@@ -107,6 +136,18 @@ function StickerGrid({
);
}
+function StickerRow({ items }: { items: StickerData[] }) {
+ return (
+
+ {items.map((item) => (
+
+
+
+ ))}
+
+ );
+}
+
function EmptyView({ message }: { message: string }) {
return (
@@ -122,88 +163,170 @@ function EmptyView({ message }: { message: string }) {
);
}
+function RegionBanner({ region }: { region: string }) {
+ return (
+
+ );
+}
+
function StickersContentView() {
const {
- searchQuery,
- viewMode,
- searchResults,
- recentStickers,
- isSearching,
+ browseContent,
clearRecentStickers,
+ isBrowsing,
+ isSearching,
+ searchQuery,
+ searchResults,
+ selectedCategory,
+ setSelectedCategory,
+ viewMode,
} = useStickersStore();
- const itemsToDisplay = useMemo(() => {
- if (viewMode === "search" && searchResults) {
- return searchResults.items;
+ if (viewMode === "search") {
+ if (isSearching) {
+ return (
+
+
+
+ );
}
- return [];
- }, [viewMode, searchResults]);
+ if (searchResults?.items.length) {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const isRegionSearch =
+ selectedCategory === "flags" &&
+ resolveQueryToRegions({ query: normalizedQuery }) !== null;
+ const regionLabel = getRegionLabel({ query: normalizedQuery });
- const recentStickerItems = useMemo(() => {
- const items: StickerData[] = [];
- for (const stickerId of recentStickers) {
- const recentStickerItem = toRecentStickerItem({ stickerId });
- if (recentStickerItem) {
- items.push(recentStickerItem);
- }
+ return (
+
+ {isRegionSearch &&
}
+
+
+ {searchResults.total} results
+
+
+
+
+ );
}
- return items;
- }, [recentStickers]);
+
+ // "all" tab search — sections are in browseContent, fall through to section rendering below
+ if (selectedCategory !== "all" && searchQuery) {
+ return
;
+ }
+ }
+
+ if (isBrowsing && !browseContent) {
+ return (
+
+
+
+ );
+ }
+
+ if (!browseContent?.sections.length) {
+ const categoryLabel = STICKER_CATEGORIES[selectedCategory];
+ return (
+
+ );
+ }
return (
-
- {recentStickerItems.length > 0 && viewMode === "browse" && (
-
-
-
-
Recent
-
-
-
-
-
-
-
-
- Clear recent stickers
-
-
-
+
+ {browseContent.sections.map((section) => (
+ {
+ setSelectedCategory({ category });
+ }}
+ />
+ ))}
+
+ );
+}
+
+function StickerSection({
+ section,
+ onClearRecent,
+ onSeeAll,
+}: {
+ section: StickerBrowseSection;
+ onClearRecent: () => void;
+ onSeeAll: (category: StickerCategory) => void;
+}) {
+ const hasHeader =
+ Boolean(section.title) || section.id === "recent" || section.action;
+
+ return (
+
+ {hasHeader && (
+
+ {section.title ? (
+
{section.title}
+ ) : (
+
+ )}
+
+
+ {section.id === "recent" && (
+
+ Clear
+
+ )}
+
+ {section.action?.type === "see-all" && section.action.category && (
+ {
+ onSeeAll(section.action?.category as StickerCategory);
+ }}
+ >
+ See all
+
+ )}
-
)}
- {viewMode === "search" && (
-
- {isSearching ? (
-
-
-
- ) : searchResults?.items.length ? (
-
-
-
- {searchResults.total} results
-
-
-
-
- ) : searchQuery ? (
-
- ) : null}
-
+ {section.layout === "row" ? (
+
+ ) : (
+
)}
);
@@ -212,65 +335,116 @@ function StickersContentView() {
interface StickerItemProps {
item: StickerData;
shouldCapSize?: boolean;
+ containerClassName?: string;
}
-function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
- const { addingSticker, addStickerToTimeline } = useStickersStore();
- const isAdding = addingSticker === item.id;
+function StickerItem({
+ item,
+ shouldCapSize = false,
+ containerClassName,
+}: StickerItemProps) {
+ const editor = useEditor();
+ const { addToRecentStickers } = useStickersStore();
+ const [isAdding, setIsAdding] = useState(false);
const [hasImageError, setHasImageError] = useState(false);
useEffect(() => {
if (!item.id) {
return;
}
+
setHasImageError(false);
}, [item.id]);
const displayName = item.name;
+ const shapePreset =
+ item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
const handleAdd = async () => {
+ setIsAdding(true);
try {
- await addStickerToTimeline({
- stickerId: item.id,
- name: item.name,
+ const currentTime = editor.playback.getCurrentTime();
+
+ let element:
+ | ReturnType
+ | ReturnType;
+ if (shapePreset) {
+ element = buildGraphicElement({
+ definitionId: shapePreset.definitionId,
+ name: shapePreset.name,
+ startTime: currentTime,
+ params: shapePreset.params,
+ });
+ } else {
+ const { width: intrinsicWidth, height: intrinsicHeight } =
+ await resolveStickerIntrinsicSize({ stickerId: item.id });
+ element = buildStickerElement({
+ stickerId: item.id,
+ name: item.name,
+ startTime: currentTime,
+ intrinsicWidth,
+ intrinsicHeight,
+ });
+ }
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto" },
+ element,
});
+
+ addToRecentStickers({ stickerId: item.id });
} catch (error) {
console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline");
+ } finally {
+ setIsAdding(false);
}
};
- const preview = hasImageError ? (
-
-
- {displayName}
-
-
- ) : (
-
-
{
- setHasImageError(true);
- }}
- loading="lazy"
- unoptimized
- />
+ const preview = (
+
+ {hasImageError ? (
+
+ {displayName}
+
+ ) : (
+ setHasImageError(true)}
+ loading="lazy"
+ unoptimized
+ />
+ )}
);
+ const dragData: TimelineDragData = shapePreset
+ ? {
+ id: item.id,
+ type: "graphic",
+ name: displayName,
+ definitionId: shapePreset.definitionId,
+ params: shapePreset.params ?? {},
+ }
+ : {
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ };
+
return (
{isAdding && (
@@ -299,36 +468,3 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
);
}
-
-function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
- const stickerIdParts = stickerId.split(":");
- if (stickerIdParts.length <= 1) {
- return stickerId;
- }
- return (
- stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
- stickerId
- );
-}
-
-function toRecentStickerItem({
- stickerId,
-}: {
- stickerId: string;
-}): StickerData | null {
- try {
- const { providerId } = parseStickerId({ stickerId });
- return {
- id: stickerId,
- provider: providerId,
- name: getStickerNameFromId({ stickerId }),
- previewUrl: resolveStickerId({
- stickerId,
- options: { width: 64, height: 64 },
- }),
- metadata: {},
- };
- } catch {
- return null;
- }
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx
index 3673acc3..075cc25b 100644
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -1,7 +1,7 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
import { buildTextElement } from "@/lib/timeline/element-utils";
export function TextView() {
@@ -12,7 +12,7 @@ export function TextView() {
if (!activeScene) return;
const element = buildTextElement({
- raw: DEFAULT_TEXT_ELEMENT,
+ raw: DEFAULTS.text.element,
startTime: currentTime,
});
@@ -33,9 +33,9 @@ export function TextView() {
}
dragData={{
id: "temp-text-id",
- type: DEFAULT_TEXT_ELEMENT.type,
- name: DEFAULT_TEXT_ELEMENT.name,
- content: DEFAULT_TEXT_ELEMENT.content,
+ type: DEFAULTS.text.element.type,
+ name: DEFAULTS.text.element.name,
+ content: DEFAULTS.text.element.content,
}}
aspectRatio={1}
onAddToTimeline={handleAddToTimeline}
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx
index d4bcdcae..13226378 100644
--- a/apps/web/src/components/editor/panels/preview/context-menu.tsx
+++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx
@@ -4,9 +4,12 @@ import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
+ ContextMenuSeparator,
} from "@/components/ui/context-menu";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
import { usePreviewStore } from "@/stores/preview-store";
+import { toast } from "sonner";
export function PreviewContextMenu({
onToggleFullscreen,
@@ -16,25 +19,55 @@ export function PreviewContextMenu({
containerRef: React.RefObject;
}) {
const editor = useEditor();
+ const viewport = usePreviewViewport();
const { overlays, setOverlayVisibility } = usePreviewStore();
+ const handleCopySnapshot = async () => {
+ const result = await editor.renderer.copySnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to copy snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
+ const handleSaveSnapshot = async () => {
+ const result = await editor.renderer.saveSnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to save snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
return (
+
+ Fit to screen
+
+
Full screen
- editor.renderer.saveSnapshot()} inset>
+
Save snapshot
+
+ Copy snapshot
+
+
setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
}
>
- Show bookmarks
+ Show bookmark notes
- Show grid
);
}
diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
new file mode 100644
index 00000000..ad7a621f
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { getGuideById } from "@/lib/guides";
+import { usePreviewStore } from "@/stores/preview-store";
+import { usePreviewViewport } from "./preview-viewport";
+
+export function GuideOverlay() {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const viewport = usePreviewViewport();
+ const guide = getGuideById(activeGuide);
+
+ if (!guide) {
+ return null;
+ }
+
+ return (
+
+ {guide.renderOverlay({
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx
new file mode 100644
index 00000000..e6d3df51
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/guide-popover.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { AnimatePresence, motion } from "motion/react";
+import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides";
+import { usePreviewStore } from "@/stores/preview-store";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/utils/ui";
+
+export function GridPopover({ children }: { children: React.ReactNode }) {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const toggleGuide = usePreviewStore((state) => state.toggleGuide);
+ const activeGuideDef = getGuideById(activeGuide);
+ const options = activeGuideDef?.renderOptions?.();
+
+ return (
+
+ {children}
+
+
+
Guides
+
+ {GUIDE_REGISTRY.map((guide) => (
+ toggleGuide(guide.id)}
+ />
+ ))}
+
+
+
+ {options && (
+
+
+ {options}
+
+ )}
+
+
+
+ );
+}
+
+function GridItem({
+ label,
+ preview,
+ isSelected,
+ onClick,
+}: {
+ label: string;
+ preview: React.ReactNode;
+ isSelected?: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+ {preview}
+
+
+ {label}
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
new file mode 100644
index 00000000..44b8a7f1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
@@ -0,0 +1,332 @@
+"use client";
+
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { IconSvgElement } from "@hugeicons/react";
+
+export const HANDLE_SIZE = 10;
+export const HANDLE_HIT_AREA_SIZE = 18;
+export const ICON_HANDLE_RADIUS = 10;
+export const EDGE_HANDLE_THIN_SIZE = 6;
+export const EDGE_HANDLE_THICK_SIZE = 14;
+export const LINE_HIT_AREA_SIZE = 48;
+
+export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
+ const normalized = ((angleDeg % 180) + 180) % 180;
+ if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
+ if (normalized < 67.5) return "cursor-nwse-resize";
+ if (normalized < 112.5) return "cursor-ns-resize";
+ return "cursor-nesw-resize";
+}
+
+export function HandleButton({
+ screen,
+ cursor,
+ hitAreaSize,
+ className,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ children,
+}: {
+ screen: { x: number; y: number };
+ cursor?: string;
+ hitAreaSize: number;
+ className?: string;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+ children: React.ReactNode;
+}) {
+ return (
+ event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ {children}
+
+ );
+}
+
+export function CornerHandle({
+ cursor,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ cursor?: string;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+export function EdgeHandle({
+ edge,
+ screen,
+ rotation,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ edge: "right" | "left" | "bottom";
+ screen: { x: number; y: number };
+ rotation: number;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ const isHorizontalEdge = edge === "right" || edge === "left";
+ const width = isHorizontalEdge
+ ? EDGE_HANDLE_THIN_SIZE
+ : EDGE_HANDLE_THICK_SIZE;
+ const height = isHorizontalEdge
+ ? EDGE_HANDLE_THICK_SIZE
+ : EDGE_HANDLE_THIN_SIZE;
+ const cursor = getResizeCursor({
+ angleDeg: isHorizontalEdge ? rotation : rotation + 90,
+ });
+
+ return (
+
+
+
+ );
+}
+
+export function IconHandle({
+ icon,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ icon: IconSvgElement;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+export function BoundingBoxOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ cursor,
+ dashed = false,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ cursor?: string;
+ dashed?: boolean;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+export function ShapeOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ pathData,
+ cursor,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ pathData: string;
+ cursor?: string;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+export function LineOverlay({
+ start,
+ end,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ const dx = end.x - start.x;
+ const dy = end.y - start.y;
+ const length = Math.sqrt(dx * dx + dy * dy);
+ const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const cx = (start.x + end.x) / 2;
+ const cy = (start.y + end.y) / 2;
+
+ const sharedStyle = {
+ left: cx - length / 2,
+ width: length,
+ transform: `rotate(${angleDeg}deg)`,
+ transformOrigin: "center center",
+ };
+
+ return (
+ <>
+ {onPointerDown && (
+
+ )}
+
+ >
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index df60db01..c625b9bf 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useMemo, useRef } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/hooks/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop";
@@ -12,50 +12,70 @@ import { buildScene } from "@/services/renderer/scene-builder";
import { getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
+import { GuideOverlay } from "./guide-overlay";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { usePreviewStore } from "@/stores/preview-store";
import { PreviewContextMenu } from "./context-menu";
import { PreviewToolbar } from "./toolbar";
+import {
+ PreviewViewportProvider,
+ usePreviewViewportState,
+} from "./preview-viewport";
function usePreviewSize() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const canvasSize = useEditor(
+ (e) => e.project.getActive()?.settings.canvasSize,
+ );
return {
- width: activeProject?.settings.canvasSize.width,
- height: activeProject?.settings.canvasSize.height,
+ width: canvasSize?.width,
+ height: canvasSize?.height,
};
}
+function normalizeWheelDelta({
+ delta,
+ deltaMode,
+ pageSize,
+}: {
+ delta: number;
+ deltaMode: number;
+ pageSize: number;
+}): number {
+ if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
+ return delta * 16;
+ }
+
+ if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
+ return delta * pageSize;
+ }
+
+ return delta;
+}
+
export function PreviewPanel() {
const containerRef = useRef(null);
- const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
+ const { toggleFullscreen } = useFullscreen({ containerRef });
return (
);
}
function RenderTreeController() {
const editor = useEditor();
- const tracks = editor.timeline.getTracks();
- const mediaAssets = editor.media.getAssets();
- const activeProject = editor.project.getActive();
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const activeProject = useEditor((e) => e.project.getActive());
const { width, height } = usePreviewSize();
@@ -79,23 +99,30 @@ function RenderTreeController() {
}
function PreviewCanvas({
- onToggleFullscreen,
containerRef,
+ onToggleFullscreen,
}: {
- onToggleFullscreen: () => void;
containerRef: React.RefObject;
+ onToggleFullscreen: () => void;
}) {
const canvasRef = useRef(null);
- const outerContainerRef = useRef(null);
- const canvasBoundsRef = useRef(null);
+ const viewportRef = useRef(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef(null);
const renderingRef = useRef(false);
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
- const containerSize = useContainerSize({ containerRef: outerContainerRef });
+ const viewportSize = useContainerSize({ containerRef: viewportRef });
const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const renderTree = useEditor((e) => e.renderer.getRenderTree());
const { overlays } = usePreviewStore();
+ const viewport = usePreviewViewportState({
+ canvasHeight: nativeHeight,
+ canvasWidth: nativeWidth,
+ viewportHeight: viewportSize.height,
+ viewportRef,
+ viewportWidth: viewportSize.width,
+ });
const renderer = useMemo(() => {
return new CanvasRenderer({
@@ -105,37 +132,6 @@ function PreviewCanvas({
});
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
- const displaySize = useMemo(() => {
- if (
- !nativeWidth ||
- !nativeHeight ||
- containerSize.width === 0 ||
- containerSize.height === 0
- ) {
- return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 };
- }
-
- const paddingBuffer = 4;
- const availableWidth = containerSize.width - paddingBuffer;
- const availableHeight = containerSize.height - paddingBuffer;
-
- const aspectRatio = nativeWidth / nativeHeight;
- const containerAspect = availableWidth / availableHeight;
-
- const displayWidth =
- containerAspect > aspectRatio
- ? availableHeight * aspectRatio
- : availableWidth;
- const displayHeight =
- containerAspect > aspectRatio
- ? availableHeight
- : availableWidth / aspectRatio;
-
- return { width: displayWidth, height: displayHeight };
- }, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]);
-
- const renderTree = editor.renderer.getRenderTree();
-
const render = useCallback(() => {
if (canvasRef.current && renderTree && !renderingRef.current) {
const time = editor.playback.getCurrentTime();
@@ -168,44 +164,130 @@ function PreviewCanvas({
useRafLoop(render);
+ useEffect(() => {
+ const container = viewportRef.current;
+ if (!container) return;
+
+ let pendingZoomDelta = 0;
+ let pendingPanDeltaX = 0;
+ let pendingPanDeltaY = 0;
+ let zoomRafId: ReturnType | null = null;
+ let panRafId: ReturnType | null = null;
+
+ const onWheel = (event: WheelEvent) => {
+ const normalizedDeltaX = normalizeWheelDelta({
+ delta: event.deltaX,
+ deltaMode: event.deltaMode,
+ pageSize: container.clientWidth,
+ });
+ const normalizedDeltaY = normalizeWheelDelta({
+ delta: event.deltaY,
+ deltaMode: event.deltaMode,
+ pageSize: container.clientHeight,
+ });
+ const isZoomGesture = event.ctrlKey || event.metaKey;
+ if (isZoomGesture) {
+ event.preventDefault();
+ pendingZoomDelta += normalizedDeltaY;
+
+ if (zoomRafId === null) {
+ zoomRafId = requestAnimationFrame(() => {
+ const cappedDelta =
+ Math.sign(pendingZoomDelta) *
+ Math.min(Math.abs(pendingZoomDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+
+ viewport.scaleZoom({ factor: zoomFactor });
+ pendingZoomDelta = 0;
+ zoomRafId = null;
+ });
+ }
+
+ return;
+ }
+
+ if (!viewport.canPan) {
+ return;
+ }
+
+ if (normalizedDeltaX === 0 && normalizedDeltaY === 0) {
+ return;
+ }
+
+ event.preventDefault();
+ pendingPanDeltaX += normalizedDeltaX;
+ pendingPanDeltaY += normalizedDeltaY;
+
+ if (panRafId === null) {
+ panRafId = requestAnimationFrame(() => {
+ viewport.panByScreenDelta({
+ deltaX: pendingPanDeltaX,
+ deltaY: pendingPanDeltaY,
+ });
+ pendingPanDeltaX = 0;
+ pendingPanDeltaY = 0;
+ panRafId = null;
+ });
+ }
+ };
+
+ container.addEventListener("wheel", onWheel, {
+ capture: true,
+ passive: false,
+ });
+
+ return () => {
+ container.removeEventListener("wheel", onWheel, {
+ capture: true,
+ });
+ if (zoomRafId !== null) {
+ cancelAnimationFrame(zoomRafId);
+ }
+ if (panRafId !== null) {
+ cancelAnimationFrame(panRafId);
+ }
+ };
+ }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]);
+
return (
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {overlays.bookmarks &&
}
+
+
+
-
- {overlays.bookmarks && }
-
-
-
-
-
+
+
+
+
+
);
}
diff --git a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
deleted file mode 100644
index 44efb4c8..00000000
--- a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-"use client";
-
-import { usePreviewStore } from "@/stores/preview-store";
-import Image from "next/image";
-
-function TikTokGuide() {
- return (
-
-
-
- );
-}
-
-export function LayoutGuideOverlay() {
- const { layoutGuide } = usePreviewStore();
-
- if (layoutGuide.platform === null) return null;
- if (layoutGuide.platform === "tiktok") return ;
-
- return null;
-}
diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
new file mode 100644
index 00000000..0c11f27a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useMaskHandles } from "@/hooks/use-mask-handles";
+import { masksRegistry } from "@/lib/masks";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import type { ParamValues } from "@/lib/params";
+import type { RectangleMaskParams } from "@/lib/masks/types";
+import {
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ LineOverlay,
+ BoundingBoxOutline,
+ ShapeOutline,
+} from "./handle-primitives";
+import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
+
+function hasRectangleOutlineParams(
+ params: ParamValues,
+): params is RectangleMaskParams {
+ return (
+ typeof params.centerX === "number" &&
+ typeof params.centerY === "number" &&
+ typeof params.width === "number" &&
+ typeof params.height === "number"
+ );
+}
+
+export function MaskHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}) {
+ const viewport = usePreviewViewport();
+ const {
+ selectedWithMask,
+ handlePositions,
+ linePoints,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ } = useMaskHandles({ onSnapLinesChange });
+
+ if (!selectedWithMask) return null;
+
+ const toOverlay = ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) =>
+ viewport.canvasToOverlay({
+ canvasX,
+ canvasY,
+ });
+
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+ const { bounds } = selectedWithMask;
+ const maskRotation = selectedWithMask.mask.params.rotation;
+
+ const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
+
+ const rectangleOutlineProps = hasRectangleOutlineParams(
+ selectedWithMask.mask.params,
+ )
+ ? {
+ center: toOverlay({
+ canvasX:
+ bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
+ canvasY:
+ bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
+ }),
+ outlineWidth:
+ selectedWithMask.mask.params.width * bounds.width * scaleX,
+ outlineHeight:
+ selectedWithMask.mask.params.height * bounds.height * scaleY,
+ rotation: maskRotation,
+ }
+ : null;
+
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
+ return (
+
+ {def.overlayShape === "line" && linePoints && (
+
+ handlePointerDown({ event, handleId: "position" })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ )}
+ {def.overlayShape === "box" && rectangleOutlineProps && (
+ def.buildOverlayPath ? (
+ <>
+
+
+ handlePointerDown({ event, handleId: "position" })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ >
+ ) : (
+
+ handlePointerDown({ event, handleId: "position" })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ )
+ )}
+ {handlePositions.map((handle) => {
+ const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
+
+ if (handle.id === "rotation") {
+ return (
+
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.id === "feather") {
+ return (
+
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.id === "right" || handle.id === "left") {
+ return (
+
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.id === "bottom" || handle.id === "top") {
+ return (
+
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (
+ handle.id === "top-left" ||
+ handle.id === "top-right" ||
+ handle.id === "bottom-left" ||
+ handle.id === "bottom-right" ||
+ handle.id === "scale"
+ ) {
+ return (
+
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ return (
+
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
index 4c33a1a1..65c11726 100644
--- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
@@ -1,54 +1,117 @@
+import { useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
+import type { SnapLine } from "@/lib/preview/preview-snap";
import { TransformHandles } from "./transform-handles";
+import { MaskHandles } from "./mask-handles";
import { SnapGuides } from "./snap-guides";
import { TextEditOverlay } from "./text-edit-overlay";
+import { usePropertiesStore } from "../properties/stores/properties-store";
+import { useEditor } from "@/hooks/use-editor";
+
+export function PreviewInteractionOverlay() {
+ const [snapLines, setSnapLines] = useState([]);
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
+
+ const selectedRef =
+ selectedElements.length === 1 ? selectedElements[0] : null;
+ const activeTrack = selectedRef
+ ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
+ : null;
+ const activeElement =
+ activeTrack?.elements.find(
+ (element) => element.id === selectedRef?.elementId,
+ ) ?? null;
+ const isMaskMode = activeElement
+ ? activeTabPerType[activeElement.type] === "masks"
+ : false;
-export function PreviewInteractionOverlay({
- canvasRef,
- containerRef,
-}: {
- canvasRef: React.RefObject;
- containerRef: React.RefObject;
-}) {
const {
onPointerDown,
onPointerMove,
onPointerUp,
onDoubleClick,
- snapLines,
editingText,
commitTextEdit,
- cancelTextEdit,
- } = usePreviewInteraction({ canvasRef });
+ } = usePreviewInteraction({
+ onSnapLinesChange: setSnapLines,
+ isMaskMode,
+ });
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerDown({ event })) {
+ return;
+ }
+
+ onPointerDown(event);
+ };
+
+ const handlePointerMove = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerMove({ event })) {
+ return;
+ }
+
+ onPointerMove(event);
+ };
+
+ const handlePointerUp = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerUp({ event })) {
+ return;
+ }
+
+ onPointerUp(event);
+ };
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ const x = e.nativeEvent.offsetX;
+ const y = e.nativeEvent.offsetY;
+ const outsideCanvas =
+ x < viewport.sceneLeft ||
+ x > viewport.sceneLeft + viewport.sceneWidth ||
+ y < viewport.sceneTop ||
+ y > viewport.sceneTop + viewport.sceneHeight;
+ if (outsideCanvas) {
+ e.stopPropagation();
+ }
+ };
return (
- {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
e.preventDefault()}
+ onContextMenu={handleContextMenu}
/>
{editingText ? (
+ ) : isMaskMode ? (
+
) : (
-
+
)}
-
+
);
}
diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
new file mode 100644
index 00000000..e38183af
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
@@ -0,0 +1,576 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ canvasToOverlay,
+ getDisplayScale,
+ positionToOverlay,
+ screenPixelsToLogicalThreshold,
+ screenToCanvas,
+} from "@/lib/preview/preview-coords";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import { PREVIEW_ZOOM } from "@/constants/editor-constants";
+
+const MIDDLE_MOUSE_BUTTON = 1;
+const IS_AT_FIT_EPSILON = 0.001;
+const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
+
+interface PreviewViewportContextValue {
+ canPan: boolean;
+ isAtFit: boolean;
+ isAtActualSize: boolean;
+ isPanning: boolean;
+ sceneHeight: number;
+ sceneLeft: number;
+ sceneTop: number;
+ sceneWidth: number;
+ zoomPercent: number;
+ canvasToOverlay: ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) => { x: number; y: number };
+ fitToScreen: () => void;
+ getDisplayScale: () => { x: number; y: number };
+ handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
+ positionToOverlay: ({
+ positionX,
+ positionY,
+ }: {
+ positionX: number;
+ positionY: number;
+ }) => { x: number; y: number };
+ panByScreenDelta: ({
+ deltaX,
+ deltaY,
+ }: {
+ deltaX: number;
+ deltaY: number;
+ }) => void;
+ resetPan: () => void;
+ scaleZoom: ({ factor }: { factor: number }) => void;
+ screenPixelsToLogicalThreshold: ({
+ screenPixels,
+ }: {
+ screenPixels: number;
+ }) => { x: number; y: number };
+ screenToCanvas: ({
+ clientX,
+ clientY,
+ }: {
+ clientX: number;
+ clientY: number;
+ }) => { x: number; y: number } | null;
+ setActualSize: () => void;
+ setViewportPercent: ({ percent }: { percent: number }) => void;
+ zoomIn: () => void;
+ zoomOut: () => void;
+}
+
+const PreviewViewportContext =
+ createContext
(null);
+
+interface PreviewViewportProviderProps {
+ children: React.ReactNode;
+ value: PreviewViewportContextValue;
+}
+
+interface PreviewViewportStateOptions {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportRef: React.RefObject;
+ viewportWidth: number;
+}
+
+interface PanSession {
+ centerX: number;
+ centerY: number;
+ clientX: number;
+ clientY: number;
+ pointerId: number;
+}
+
+function getClampedCenterAxis({
+ axisSize,
+ center,
+ scale,
+ viewportSize,
+}: {
+ axisSize: number;
+ center: number;
+ scale: number;
+ viewportSize: number;
+}): number {
+ if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
+ return axisSize / 2;
+ }
+
+ const visibleHalfSpan = viewportSize / (2 * scale);
+ if (visibleHalfSpan >= axisSize / 2) {
+ return axisSize / 2;
+ }
+
+ return clamp({
+ value: center,
+ min: visibleHalfSpan,
+ max: axisSize - visibleHalfSpan,
+ });
+}
+
+function clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX,
+ centerY,
+ scale,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ centerX: number;
+ centerY: number;
+ scale: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): { x: number; y: number } {
+ return {
+ x: getClampedCenterAxis({
+ center: centerX,
+ axisSize: canvasWidth,
+ scale,
+ viewportSize: viewportWidth,
+ }),
+ y: getClampedCenterAxis({
+ center: centerY,
+ axisSize: canvasHeight,
+ scale,
+ viewportSize: viewportHeight,
+ }),
+ };
+}
+
+function getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): number {
+ if (
+ canvasHeight <= 0 ||
+ canvasWidth <= 0 ||
+ viewportHeight <= 0 ||
+ viewportWidth <= 0
+ ) {
+ return 1;
+ }
+
+ return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
+}
+
+function getClampedZoom({ zoom }: { zoom: number }): number {
+ return clamp({
+ value: zoom,
+ min: PREVIEW_ZOOM.min,
+ max: PREVIEW_ZOOM.max,
+ });
+}
+
+export function PreviewViewportProvider({
+ children,
+ value,
+}: PreviewViewportProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function usePreviewViewportState({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportRef,
+ viewportWidth,
+}: PreviewViewportStateOptions): PreviewViewportContextValue {
+ const [zoom, setZoomState] = useState(1);
+ const [center, setCenter] = useState(() => ({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ }));
+ const [isPanning, setIsPanning] = useState(false);
+ const panSessionRef = useRef(null);
+ const centerRef = useRef(center);
+ centerRef.current = center;
+
+ const fitScale = useMemo(
+ () =>
+ getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [canvasHeight, canvasWidth, viewportHeight, viewportWidth],
+ );
+ const viewportScale = fitScale * zoom;
+ const geometry = useMemo(
+ () => ({
+ canvasHeight,
+ canvasWidth,
+ centerX: center.x,
+ centerY: center.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [
+ canvasHeight,
+ canvasWidth,
+ center.x,
+ center.y,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ ],
+ );
+
+ const scaleZoom = useCallback(({ factor }: { factor: number }) => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * factor,
+ }),
+ );
+ }, []);
+
+ const panByScreenDelta = useCallback(
+ ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
+ if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
+ return;
+ }
+
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x + deltaX / viewportScale,
+ centerY: previousCenter.y + deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ },
+ [
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ zoom,
+ ],
+ );
+
+ const resetPan = useCallback(() => {
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const fitToScreen = useCallback(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const zoomIn = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const zoomOut = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom / PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const setActualSize = useCallback(() => {
+ const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
+ setZoomState(
+ getClampedZoom({
+ zoom: actualSizeZoom,
+ }),
+ );
+ }, [fitScale]);
+
+ const setViewportPercent = useCallback(
+ ({ percent }: { percent: number }) => {
+ const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
+ setZoomState(getClampedZoom({ zoom: targetZoom }));
+ },
+ [fitScale],
+ );
+
+ const getViewportRect = useCallback(
+ () => viewportRef.current?.getBoundingClientRect() ?? null,
+ [viewportRef],
+ );
+
+ const screenToCanvasWithViewport = useCallback(
+ ({ clientX, clientY }: { clientX: number; clientY: number }) => {
+ const viewportRect = getViewportRect();
+ if (!viewportRect) return null;
+
+ return screenToCanvas({
+ clientX,
+ clientY,
+ geometry,
+ viewportRect,
+ });
+ },
+ [getViewportRect, geometry],
+ );
+
+ const canvasToOverlayWithViewport = useCallback(
+ ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
+ canvasToOverlay({
+ canvasX,
+ canvasY,
+ geometry,
+ }),
+ [geometry],
+ );
+
+ const positionToOverlayWithViewport = useCallback(
+ ({ positionX, positionY }: { positionX: number; positionY: number }) =>
+ positionToOverlay({
+ geometry,
+ positionX,
+ positionY,
+ }),
+ [geometry],
+ );
+
+ const getDisplayScaleWithViewport = useCallback(
+ () => getDisplayScale({ geometry }),
+ [geometry],
+ );
+
+ const getLogicalThresholdForScreenPixels = useCallback(
+ ({ screenPixels }: { screenPixels: number }) =>
+ screenPixelsToLogicalThreshold({
+ geometry,
+ screenPixels,
+ }),
+ [geometry],
+ );
+
+ const handlePanPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ panSessionRef.current = {
+ centerX: centerRef.current.x,
+ centerY: centerRef.current.y,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ pointerId: event.pointerId,
+ };
+ setIsPanning(true);
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ return true;
+ },
+ [zoom],
+ );
+
+ const handlePanPointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const deltaX = event.clientX - panSession.clientX;
+ const deltaY = event.clientY - panSession.clientY;
+ const nextCenter = clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: panSession.centerX - deltaX / viewportScale,
+ centerY: panSession.centerY - deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ });
+
+ setCenter(nextCenter);
+ return true;
+ },
+ [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
+ );
+
+ const handlePanPointerUp = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ if (
+ (event.currentTarget as HTMLElement).hasPointerCapture(
+ panSession.pointerId,
+ )
+ ) {
+ (event.currentTarget as HTMLElement).releasePointerCapture(
+ panSession.pointerId,
+ );
+ }
+
+ panSessionRef.current = null;
+ setIsPanning(false);
+ return true;
+ },
+ [],
+ );
+
+ useEffect(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ panSessionRef.current = null;
+ setIsPanning(false);
+ }, [canvasHeight, canvasWidth]);
+
+ useEffect(() => {
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x,
+ centerY: previousCenter.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
+
+ const sceneWidth = canvasWidth * viewportScale;
+ const sceneHeight = canvasHeight * viewportScale;
+ const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
+ const sceneTop = viewportHeight / 2 - center.y * viewportScale;
+ const canPan = zoom > 1;
+ const zoomPercent = Math.round(viewportScale * 100);
+
+ return useMemo(
+ () => ({
+ canPan,
+ isAtActualSize: isNearlyEqual({
+ leftValue: viewportScale,
+ rightValue: 1,
+ epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
+ }),
+ isAtFit: isNearlyEqual({
+ leftValue: zoom,
+ rightValue: 1,
+ epsilon: IS_AT_FIT_EPSILON,
+ }),
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlay: canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScale: getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlay: positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
+ screenToCanvas: screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ }),
+ [
+ canPan,
+ viewportScale,
+ zoom,
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ getLogicalThresholdForScreenPixels,
+ screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ ],
+ );
+}
+
+export function usePreviewViewport() {
+ const context = useContext(PreviewViewportContext);
+ if (!context) {
+ throw new Error(
+ "usePreviewViewport must be used within PreviewViewportProvider",
+ );
+ }
+
+ return context;
+}
diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
index 4aa714a1..744304ea 100644
--- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx
+++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
@@ -1,43 +1,25 @@
"use client";
-import { useEditor } from "@/hooks/use-editor";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import type { SnapLine } from "@/lib/preview/preview-snap";
-import { positionToOverlay } from "@/lib/preview/preview-coords";
-export function SnapGuides({
- lines,
- canvasRef,
- containerRef,
-}: {
- lines: SnapLine[];
- canvasRef: React.RefObject;
- containerRef: React.RefObject;
-}) {
- const editor = useEditor();
- const canvasSize = editor.project.getActive().settings.canvasSize;
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
+export function SnapGuides({ lines }: { lines: SnapLine[] }) {
+ const viewport = usePreviewViewport();
- if (!canvasRect || !containerRect || lines.length === 0) {
+ if (lines.length === 0) {
return null;
}
const toOverlayX = (logicalX: number) =>
- positionToOverlay({
+ viewport.positionToOverlay({
positionX: logicalX,
positionY: 0,
- canvasRect,
- containerRect,
- canvasSize,
}).x;
const toOverlayY = (logicalY: number) =>
- positionToOverlay({
+ viewport.positionToOverlay({
positionX: 0,
positionY: logicalY,
- canvasRect,
- containerRect,
- canvasSize,
}).y;
return (
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 26649b26..d3c61a24 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
@@ -1,38 +1,89 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
-import type { TextElement } from "@/types/timeline";
+import type { TextElement } from "@/lib/timeline";
import {
- positionToOverlay,
- getDisplayScale,
-} from "@/lib/preview/preview-coords";
-import {
- DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ getMetricAscent,
+ getMetricDescent,
+ setCanvasLetterSpacing,
+} from "@/lib/text/layout";
-const TEXT_BACKGROUND_PADDING = "4px 8px";
-const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
+let cachedCanvas: HTMLCanvasElement | null = null;
+
+function getMeasurementContext(): CanvasRenderingContext2D | null {
+ if (!cachedCanvas) cachedCanvas = document.createElement("canvas");
+ return cachedCanvas.getContext("2d");
+}
+
+/**
+ * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than
+ * actualBoundingBox to model where the browser places the baseline within the
+ * line box, then measures actual glyph bounds to find the visual center.
+ */
+function measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx,
+ lineHeightPx,
+ displayFontSize,
+}: {
+ lines: string[];
+ fontString: string;
+ letterSpacingPx: number;
+ lineHeightPx: number;
+ displayFontSize: number;
+}): number {
+ const ctx = getMeasurementContext();
+ if (!ctx) return 0;
+
+ ctx.font = fontString;
+ ctx.textBaseline = "alphabetic";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx });
+
+ const probe = ctx.measureText("M");
+ const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8;
+ const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2;
+ const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2;
+
+ let visualTop = Number.POSITIVE_INFINITY;
+ let visualBottom = Number.NEGATIVE_INFINITY;
+
+ for (let i = 0; i < lines.length; i++) {
+ const metrics = ctx.measureText(lines[i] || " ");
+ const baseline = i * lineHeightPx + halfLeading + fontAscent;
+ visualTop = Math.min(
+ visualTop,
+ baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ visualBottom = Math.max(
+ visualBottom,
+ baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ }
+
+ const cssBlockHeight = lines.length * lineHeightPx;
+ return (visualTop + visualBottom) / 2 - cssBlockHeight / 2;
+}
export function TextEditOverlay({
- canvasRef,
- containerRef,
trackId,
elementId,
element,
onCommit,
- onCancel,
}: {
- canvasRef: React.RefObject;
- containerRef: React.RefObject;
trackId: string;
elementId: string;
element: TextElement;
onCommit: () => void;
- onCancel: () => void;
}) {
const editor = useEditor();
+ const viewport = usePreviewViewport();
const divRef = useRef(null);
useEffect(() => {
@@ -60,59 +111,67 @@ export function TextEditOverlay({
const { key } = event;
if (key === "Escape") {
event.preventDefault();
- onCancel();
+ onCommit();
return;
}
},
- [onCancel],
+ [onCommit],
);
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
const canvasSize = editor.project.getActive().settings.canvasSize;
- if (!canvasRect || !containerRect || !canvasSize) return null;
+ if (!canvasSize) return null;
- const { x: posX, y: posY } = positionToOverlay({
+ const { x: posX, y: posY } = viewport.positionToOverlay({
positionX: element.transform.position.x,
positionY: element.transform.position.y,
- canvasRect,
- containerRect,
- canvasSize,
});
- const { x: displayScaleX } = getDisplayScale({
- canvasRect,
- canvasSize,
- });
+ const { x: displayScaleX } = viewport.getDisplayScale();
const displayFontSize =
element.fontSize *
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
displayScaleX;
- const verticalAlignmentOffset =
- displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM;
-
- const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const letterSpacing = element.letterSpacing ?? 0;
+ const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX;
+ const lineHeightPx = displayFontSize * lineHeight;
+ const lines = (element.content || "").split("\n");
+ const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`;
+
+ const cssVisualCenterOffset = measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx: displayLetterSpacing,
+ lineHeightPx,
+ displayFontSize,
+ });
+
+ const bg = element.background;
const shouldShowBackground =
- element.background.enabled &&
- element.background.color &&
- element.background.color !== "transparent";
- const backgroundColor = shouldShowBackground
- ? element.background.color
- : "transparent";
+ bg.enabled && bg.color && bg.color !== "transparent";
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const displayPaddingX = shouldShowBackground
+ ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
+ const displayPaddingY = shouldShowBackground
+ ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
return (
@@ -131,13 +190,15 @@ export function TextEditOverlay({
fontWeight,
fontStyle,
textAlign: element.textAlign,
- letterSpacing: `${letterSpacing}px`,
+ letterSpacing: `${displayLetterSpacing}px`,
lineHeight,
color: element.color,
- backgroundColor,
- minHeight: displayFontSize * lineHeight,
+ backgroundColor: shouldShowBackground ? bg.color : "transparent",
+ minHeight: lineHeightPx,
textDecoration: element.textDecoration ?? "none",
- padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0,
+ padding: shouldShowBackground
+ ? `${displayPaddingY}px ${displayPaddingX}px`
+ : 0,
minWidth: 1,
}}
onInput={handleInput}
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
index 7f5cd9bc..56cb784a 100644
--- a/apps/web/src/components/editor/panels/preview/toolbar.tsx
+++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "@/lib/time";
import { invokeAction } from "@/lib/actions";
@@ -7,74 +8,144 @@ import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
+ GridTableIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { OcSocialIcon } from "@opencut/ui/icons";
+import { getGuideById } from "@/lib/guides";
import { Separator } from "@/components/ui/separator";
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+} from "@/components/ui/select";
+import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
+import { usePreviewViewport } from "./preview-viewport";
+import { GridPopover } from "./guide-popover";
+import { usePreviewStore } from "@/stores/preview-store";
export function PreviewToolbar({
- isFullscreen,
onToggleFullscreen,
}: {
- isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
- const editor = useEditor();
- const isPlaying = editor.playback.getIsPlaying();
- const currentTime = editor.playback.getCurrentTime();
- const totalDuration = editor.timeline.getTotalDuration();
- const fps = editor.project.getActive().settings.fps;
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const activeGuideDefinition = getGuideById(activeGuide);
return (
-
- editor.playback.seek({ time })}
- className="text-center"
- />
- /
-
- {formatTimeCode({
- timeInSeconds: totalDuration,
- format: "HH:MM:SS:FF",
- fps,
- })}
-
-
-
-
invokeAction("toggle-play")}
- >
-
-
-
+
+
-
-
-
+
-
+
+
+ {activeGuideDefinition ? (
+ activeGuideDefinition.renderTriggerIcon()
+ ) : (
+
+ )}
+
+
+
);
}
+
+function TimecodeDisplay() {
+ const editor = useEditor();
+ const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
+ const fps = useEditor((e) => e.project.getActive().settings.fps);
+ const [currentTime, setCurrentTime] = useState(() =>
+ editor.playback.getCurrentTime(),
+ );
+
+ useEffect(() => {
+ const handler = (e: Event) =>
+ setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
+ window.addEventListener("playback-update", handler);
+ window.addEventListener("playback-seek", handler);
+ return () => {
+ window.removeEventListener("playback-update", handler);
+ window.removeEventListener("playback-seek", handler);
+ };
+ }, []);
+
+ return (
+
+ editor.playback.seek({ time })}
+ className="text-center"
+ />
+ /
+
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+
+
+ );
+}
+
+function ZoomSelect() {
+ const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
+ usePreviewViewport();
+
+ const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
+
+ const onValueChange = (value: string) => {
+ if (value === "fit") {
+ fitToScreen();
+ } else {
+ setViewportPercent({ percent: Number(value) });
+ }
+ };
+
+ return (
+
+ {displayLabel}
+
+ Fit
+
+ {PREVIEW_ZOOM_PRESETS.map((preset) => (
+
+ {preset}%
+
+ ))}
+
+
+ );
+}
+
+function PlayPauseButton() {
+ const isPlaying = useEditor((e) => e.playback.getIsPlaying());
+
+ return (
+
invokeAction("toggle-play")}
+ >
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
index 2fcf8096..88332625 100644
--- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx
+++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
@@ -1,112 +1,55 @@
"use client";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useTransformHandles } from "@/hooks/use-transform-handles";
-import { useEditor } from "@/hooks/use-editor";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import { SnapGuides } from "./snap-guides";
-import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ getCornerPosition,
+ getEdgeHandlePosition,
+ getRotationHandlePosition,
+ type Corner,
+ type Edge,
+} from "@/lib/preview/element-bounds";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
+import {
+ BoundingBoxOutline,
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ getResizeCursor,
+} from "./handle-primitives";
import { Rotate01Icon } from "@hugeicons/core-free-icons";
-const HANDLE_SIZE = 10;
-const ROTATION_HANDLE_OFFSET = 24;
-const ROTATION_HANDLE_RADIUS = 10;
-const CORNER_HIT_AREA_SIZE = 18;
-
-type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const CORNERS: Corner[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
];
-
-export function getCornerPosition({
- bounds,
- corner,
-}: {
- bounds: ElementBounds;
- corner: Corner;
-}): { x: number; y: number } {
- const halfW = bounds.width / 2;
- const halfH = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
-
- const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
- const localY =
- corner === "top-left" || corner === "top-right" ? -halfH : halfH;
-
- return {
- x: bounds.cx + (localX * cos - localY * sin),
- y: bounds.cy + (localX * sin + localY * cos),
- };
-}
-
-export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
- x: number;
- y: number;
-} {
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
- return {
- x: bounds.cx - localY * sin,
- y: bounds.cy + localY * cos,
- };
-}
-
-export function getOverlayContext({
- canvasRef,
- containerRef,
-}: {
- canvasRef: React.RefObject
;
- containerRef: React.RefObject;
-}): {
- canvasRect: DOMRect;
- containerRect: DOMRect;
-} | null {
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
- if (!canvasRect || !containerRect) return null;
- return { canvasRect, containerRect };
-}
+const EDGES: Edge[] = ["right", "left", "bottom"];
export function TransformHandles({
- canvasRef,
- containerRef,
+ onSnapLinesChange,
}: {
- canvasRef: React.RefObject;
- containerRef: React.RefObject;
+ onSnapLinesChange?: OnSnapLinesChange;
}) {
+ const viewport = usePreviewViewport();
const {
selectedWithBounds,
hasVisualSelection,
- snapLines,
handleCornerPointerDown,
+ handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
- } = useTransformHandles({ canvasRef });
-
- const editor = useEditor();
- const canvasSize = editor.project.getActive().settings.canvasSize;
+ } = useTransformHandles({ onSnapLinesChange });
if (!hasVisualSelection || !selectedWithBounds) return null;
- const overlayContext = getOverlayContext({ canvasRef, containerRef });
- if (!overlayContext) return null;
-
const { bounds, element } = selectedWithBounds;
if (!isVisualElement(element)) return null;
- const { canvasRect, containerRect } = overlayContext;
- const displayScale = getDisplayScale({ canvasRect, canvasSize });
+ const displayScale = viewport.getDisplayScale();
const toOverlay = ({
canvasX,
@@ -115,31 +58,30 @@ export function TransformHandles({
canvasX: number;
canvasY: number;
}) =>
- canvasToOverlay({
+ viewport.canvasToOverlay({
canvasX,
canvasY,
- canvasRect,
- containerRect,
- canvasSize,
});
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
- const outlineWidth = bounds.width * displayScale.x;
- const outlineHeight = bounds.height * displayScale.y;
+ const outlineWidth = Math.abs(bounds.width) * displayScale.x;
+ const outlineHeight = Math.abs(bounds.height) * displayScale.y;
- const rotationHandle = getRotationHandlePosition({ bounds });
+ const rotationHandleCanvas = getRotationHandlePosition({ bounds });
const rotationHandleScreen = toOverlay({
- canvasX: rotationHandle.x,
- canvasY: rotationHandle.y,
+ canvasX: rotationHandleCanvas.x,
+ canvasY: rotationHandleCanvas.y,
});
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
return (
-
-
+
handleCornerPointerDown({ event, corner })
}
- onPointerMove={(event) => handlePointerMove({ event })}
- onPointerUp={(event) => handlePointerUp({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
/>
);
})}
- {
+ const edgePosition = getEdgeHandlePosition({ bounds, edge });
+ const screen = toOverlay({
+ canvasX: edgePosition.x,
+ canvasY: edgePosition.y,
+ });
+ return (
+ handleEdgePointerDown({ event, edge })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ handleRotationPointerDown({ event })}
- onPointerMove={(event) => handlePointerMove({ event })}
- onPointerUp={(event) => handlePointerUp({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
/>
);
}
-
-function BoundingBoxOutline({
- center,
- outlineWidth,
- outlineHeight,
- rotation,
-}: {
- center: { x: number; y: number };
- outlineWidth: number;
- outlineHeight: number;
- rotation: number;
-}) {
- return (
-
- );
-}
-
-function CornerHandle({
- corner,
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- corner: Corner;
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- const isNesw = corner === "top-right" || corner === "bottom-left";
-
- return (
-
event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
-
-
- );
-}
-
-function RotationHandle({
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- return (
-
event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
-
-
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx
similarity index 100%
rename from apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx
rename to apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx
diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
new file mode 100644
index 00000000..e69c6918
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
+import { formatNumberForDisplay, snapToStep } from "@/utils/math";
+import { SectionField } from "@/components/section";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { ColorPicker } from "@/components/ui/color-picker";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { KeyframeToggle } from "./keyframe-toggle";
+
+export function PropertyParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+ keyframe,
+}: {
+ param: ParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ keyframe?: {
+ isActive: boolean;
+ isDisabled: boolean;
+ onToggle: () => void;
+ };
+}) {
+ return (
+
+ ) : undefined
+ }
+ >
+
+
+ );
+}
+
+function ParamInput({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: ParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ if (param.type === "number") {
+ return (
+
+ );
+ }
+
+ if (param.type === "boolean") {
+ return (
+
{
+ onPreview(checked);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ if (param.type === "select") {
+ return (
+ {
+ onPreview(selected);
+ onCommit();
+ }}
+ >
+
+
+
+
+ {param.options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+ }
+
+ if (param.type === "color") {
+ return (
+ onPreview(`#${color}`)}
+ onChangeEnd={(color) => {
+ onPreview(`#${color}`);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ return null;
+}
+
+function NumberParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: NumberParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+}) {
+ const { min, max, step, displayMultiplier = 1 } = param;
+ const displayValue = value * displayMultiplier;
+ const clampDisplayValue = (nextDisplayValue: number) =>
+ Math.max(
+ min,
+ max !== undefined ? Math.min(max, nextDisplayValue) : nextDisplayValue,
+ );
+
+ const previewFromDisplay = (displayVal: number) => {
+ const clamped = clampDisplayValue(
+ snapToStep({ value: displayVal, step }),
+ );
+ onPreview(clamped / displayMultiplier);
+ };
+
+ const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
+
+ const draft = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: displayValue,
+ maxFractionDigits,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clampDisplayValue(snapToStep({ value: parsed, step }));
+ },
+ onPreview: previewFromDisplay,
+ onCommit,
+ });
+
+ const handleReset = () => {
+ onPreview(param.default);
+ onCommit();
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
index dbd19e4c..957ee1c9 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -4,8 +4,8 @@ import {
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
export function useKeyframedColorProperty({
trackId,
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
index 11aa049c..0f0168f8 100644
--- 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
@@ -4,8 +4,9 @@ import {
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
@@ -18,6 +19,7 @@ export function useKeyframedNumberProperty({
displayValue,
parse,
valueAtPlayhead,
+ step,
buildBaseUpdates,
}: {
trackId: string;
@@ -29,9 +31,12 @@ export function useKeyframedNumberProperty({
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
+ step?: number;
buildBaseUpdates: ({ value }: { value: number }) => Partial;
}) {
const editor = useEditor();
+ const snapValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
@@ -43,6 +48,7 @@ export function useKeyframedNumberProperty({
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
@@ -54,7 +60,7 @@ export function useKeyframedNumberProperty({
animations,
propertyPath,
time: localTime,
- value,
+ value: nextValue,
}),
},
},
@@ -68,7 +74,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
- updates: buildBaseUpdates({ value }),
+ updates: buildBaseUpdates({ value: nextValue }),
},
],
});
@@ -76,7 +82,10 @@ export function useKeyframedNumberProperty({
const propertyDraft = usePropertyDraft({
displayValue,
- parse,
+ parse: (input) => {
+ const parsedValue = parse(input);
+ return parsedValue === null ? null : snapValue(parsedValue);
+ },
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
@@ -107,13 +116,14 @@ export function useKeyframedNumberProperty({
elementId,
propertyPath,
time: localTime,
- value: valueAtPlayhead,
+ value: snapValue(valueAtPlayhead),
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
editor.timeline.upsertKeyframes({
keyframes: [
@@ -122,7 +132,7 @@ export function useKeyframedNumberProperty({
elementId,
propertyPath,
time: localTime,
- value,
+ value: nextValue,
},
],
});
@@ -134,7 +144,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
- updates: buildBaseUpdates({ value }),
+ updates: buildBaseUpdates({ value: nextValue }),
},
],
});
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
new file mode 100644
index 00000000..335e3fb6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -0,0 +1,148 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import {
+ buildGraphicParamPath,
+ getKeyframeAtTime,
+ getParamDefaultInterpolation,
+ getParamValueKind,
+ hasKeyframesForPath,
+ upsertPathKeyframe,
+} from "@/lib/animation";
+import type {
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { ParamDefinition } from "@/lib/params";
+import type { TimelineElement } from "@/lib/timeline";
+
+export interface KeyframedParamPropertyResult {
+ hasAnimatedKeyframes: boolean;
+ isKeyframedAtTime: boolean;
+ keyframeIdAtTime: string | null;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ toggleKeyframe: () => void;
+}
+
+export function useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId,
+ animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ buildBaseUpdates,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number | string | boolean;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: number | string | boolean;
+ }) => Partial;
+}): KeyframedParamPropertyResult {
+ const editor = useEditor();
+ const propertyPath = buildGraphicParamPath({ paramKey: param.key });
+ 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: KeyframedParamPropertyResult["onPreview"] = (value) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value,
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({
+ param,
+ }),
+ numericRange:
+ param.type === "number"
+ ? { min: param.min, max: param.max, step: param.step }
+ : undefined,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ 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: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ return {
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ onPreview: previewValue,
+ onCommit: () => editor.timeline.commitPreview(),
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
new file mode 100644
index 00000000..188f05cb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
@@ -0,0 +1,190 @@
+import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ VectorValue,
+} from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedVectorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ displayX,
+ displayY,
+ parseComponent,
+ step,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: VectorValue;
+ displayX: string;
+ displayY: string;
+ parseComponent: (input: string) => number | null;
+ step?: number;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: VectorValue;
+ }) => Partial;
+}) {
+ const editor = useEditor();
+ const snapComponentValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
+
+ 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 previewVector = ({ value }: { value: VectorValue }) => {
+ const nextValue = {
+ x: snapComponentValue(value.x),
+ y: snapComponentValue(value.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: nextValue,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }],
+ });
+ };
+
+ const x = usePropertyDraft({
+ displayValue: displayX,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (xVal) =>
+ previewVector({ value: { x: xVal, y: resolvedValue.y } }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const y = usePropertyDraft({
+ displayValue: displayY,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (yVal) =>
+ previewVector({ value: { x: resolvedValue.x, y: yVal } }),
+ 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: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ const commitX = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(value),
+ y: snapComponentValue(resolvedValue.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ const commitY = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(resolvedValue.x),
+ y: snapComponentValue(value),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ return {
+ x,
+ y,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitX,
+ commitY,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index cb45996d..3f3ff1f3 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -1,86 +1,95 @@
"use client";
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 { ClipEffectsProperties } from "./clip-effects-properties";
-import { EmptyView } from "./empty-view";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { usePropertiesStore } from "@/stores/properties-store";
-import { isVisualElement } from "@/lib/timeline";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
-
-function ElementProperties({
- 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;
-}
+import { usePropertiesStore } from "./stores/properties-store";
+import { getPropertiesConfig } from "./registry";
+import { cn } from "@/utils/ui";
+import { EmptyView } from "./empty-view";
export function PropertiesPanel() {
const editor = useEditor();
+ useEditor((e) => e.timeline.getTracks());
+ useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
- const clipEffectsTarget = usePropertiesStore(
- (state) => state.clipEffectsTarget,
- );
+ const { activeTabPerType, setActiveTab } = usePropertiesStore();
- const clipEffectsTrack = clipEffectsTarget
- ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId })
- : null;
- const clipEffectsElement = clipEffectsTrack?.elements.find(
- (element) => element.id === clipEffectsTarget?.elementId,
- );
- const isShowingClipEffects =
- clipEffectsTrack &&
- clipEffectsElement &&
- isVisualElement(clipEffectsElement);
+ if (selectedElements.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ if (selectedElements.length > 1) {
+ return (
+
+
+ {selectedElements.length} elements selected.0
+
+
+ );
+ }
+
+ const mediaAssets = editor.media.getAssets();
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: selectedElements,
});
+ const elementWithTrack = elementsWithTracks[0];
+
+ if (!elementWithTrack) return null;
+
+ const { element, track } = elementWithTrack;
+ const config = getPropertiesConfig({ element, mediaAssets });
+ const visibleTabs = config.tabs;
+
+ const storedTabId = activeTabPerType[element.type];
+ const isStoredTabVisible = visibleTabs.some((t) => t.id === storedTabId);
+ const activeTabId = isStoredTabVisible ? storedTabId : config.defaultTab;
+ const activeTab =
+ visibleTabs.find((t) => t.id === activeTabId) ?? visibleTabs[0];
+
+ if (!activeTab) return null;
return (
-
- {isShowingClipEffects ? (
-
- ) : selectedElements.length > 0 ? (
-
- {elementsWithTracks.map(({ track, element }) => (
-
+
+
+
+ {visibleTabs.map((tab) => (
+
+
+ setActiveTab(element.type, tab.id)}
+ aria-label={tab.label}
+ className={cn(
+ "shrink-0",
+ "h-8 w-8",
+ tab.id !== activeTab.id && "text-muted-foreground",
+ )}
+ >
+ {tab.icon}
+
+
+ {tab.label}
+
))}
-
- ) : (
-
- )}
+
+
+
+ {activeTab.content({ trackId: track.id })}
+
);
}
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx
new file mode 100644
index 00000000..706faef4
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/registry.tsx
@@ -0,0 +1,303 @@
+import type { ReactNode } from "react";
+import type {
+ EffectElement,
+ GraphicElement,
+ ImageElement,
+ MaskableElement,
+ RetimableElement,
+ StickerElement,
+ TextElement,
+ VisualElement,
+ VideoElement,
+ AudioElement,
+ TimelineElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ TextFontIcon,
+ ArrowExpandIcon,
+ RainDropIcon,
+ MusicNote03Icon,
+ MagicWand05Icon,
+ DashboardSpeed02Icon,
+} from "@hugeicons/core-free-icons";
+import { TransformTab } from "./tabs/transform-tab";
+import { BlendingTab } from "./tabs/blending-tab";
+import { AudioTab } from "./tabs/audio-tab";
+import { TextTab } from "./tabs/text-tab";
+import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
+import { MasksTab } from "./tabs/masks-tab";
+import { SpeedTab } from "./tabs/speed-tab";
+import { GraphicTab } from "./tabs/graphic-tab";
+import { OcShapesIcon } from "@opencut/ui/icons";
+
+export type TabContentProps = {
+ trackId: string;
+};
+
+export type PropertiesTabDef = {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ content: (props: TabContentProps) => ReactNode;
+};
+
+export type ElementPropertiesConfig = {
+ defaultTab: string;
+ tabs: PropertiesTabDef[];
+};
+
+function buildTransformTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "transform",
+ label: "Transform",
+ icon: ,
+ content: ({ trackId }) => (
+
+ ),
+ };
+}
+
+function buildBlendingTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "blending",
+ label: "Blending",
+ icon: ,
+ content: ({ trackId }) => (
+
+ ),
+ };
+}
+
+function buildAudioTab({
+ element,
+}: {
+ element: AudioElement | VideoElement;
+}): PropertiesTabDef {
+ return {
+ id: "audio",
+ label: "Audio",
+ icon: ,
+ content: ({ trackId }) => ,
+ };
+}
+
+function buildSpeedTab({
+ element,
+}: {
+ element: RetimableElement;
+}): PropertiesTabDef {
+ return {
+ id: "speed",
+ label: "Speed",
+ icon: ,
+ content: ({ trackId }) => ,
+ };
+}
+
+function buildMasksTab({
+ element,
+}: {
+ element: MaskableElement;
+}): PropertiesTabDef {
+ return {
+ id: "masks",
+ label: "Masks",
+ icon: ,
+ content: ({ trackId }) => ,
+ };
+}
+
+function buildClipEffectsTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: ,
+ content: ({ trackId }) => (
+
+ ),
+ };
+}
+
+function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
+ return {
+ id: "text",
+ label: "Text",
+ icon: ,
+ content: ({ trackId }) => ,
+ };
+}
+
+function buildGraphicTab({
+ element,
+}: {
+ element: GraphicElement;
+}): PropertiesTabDef {
+ return {
+ id: "graphic",
+ label: "Graphic",
+ icon: ,
+ content: ({ trackId }) => ,
+ };
+}
+
+function buildStandaloneEffectTab({
+ element,
+}: {
+ element: EffectElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: ,
+ content: ({ trackId }) => (
+
+ ),
+ };
+}
+
+function getTextConfig({
+ element,
+}: {
+ element: TextElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "text",
+ tabs: [
+ buildTextTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ ],
+ };
+}
+
+function getVideoConfig({
+ element,
+ mediaAsset,
+}: {
+ element: VideoElement;
+ mediaAsset: MediaAsset | undefined;
+}): ElementPropertiesConfig {
+ const showAudioTab = mediaAsset?.hasAudio !== false;
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ ...(showAudioTab ? [buildAudioTab({ element })] : []),
+ buildSpeedTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getImageConfig({
+ element,
+}: {
+ element: ImageElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getStickerConfig({
+ element,
+}: {
+ element: StickerElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getGraphicConfig({
+ element,
+}: {
+ element: GraphicElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "graphic",
+ tabs: [
+ buildGraphicTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getAudioConfig({
+ element,
+}: {
+ element: AudioElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "audio",
+ tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
+ };
+}
+
+function getEffectConfig({
+ element,
+}: {
+ element: EffectElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "effects",
+ tabs: [buildStandaloneEffectTab({ element })],
+ };
+}
+
+export function getPropertiesConfig({
+ element,
+ mediaAssets,
+}: {
+ element: TimelineElement;
+ mediaAssets: MediaAsset[];
+}): ElementPropertiesConfig {
+ switch (element.type) {
+ case "text":
+ return getTextConfig({ element });
+ case "video": {
+ const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
+ return getVideoConfig({ element, mediaAsset });
+ }
+ case "image":
+ return getImageConfig({ element });
+ case "sticker":
+ return getStickerConfig({ element });
+ case "graphic":
+ return getGraphicConfig({ element });
+ case "audio":
+ return getAudioConfig({ element });
+ case "effect":
+ return getEffectConfig({ element });
+ }
+}
diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
new file mode 100644
index 00000000..1783e3fc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
@@ -0,0 +1,18 @@
+import { create } from "zustand";
+
+interface PropertiesState {
+ activeTabPerType: Record;
+ setActiveTab: (elementType: string, tabId: string) => void;
+ isTransformScaleLocked: boolean;
+ setTransformScaleLocked: (locked: boolean) => void;
+}
+
+export const usePropertiesStore = create()((set) => ({
+ activeTabPerType: {},
+ setActiveTab: (elementType, tabId) =>
+ set((state) => ({
+ activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
+ })),
+ isTransformScaleLocked: false,
+ setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+}));
diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
new file mode 100644
index 00000000..41b6ec6d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
@@ -0,0 +1,109 @@
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import { resolveNumberAtTime } from "@/lib/animation";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: resolvedVolume.toFixed(1),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: 0.1,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+
+ return (
+
+
+ Audio
+
+
+
+
+ }
+ >
+ }
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
new file mode 100644
index 00000000..c360817c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
@@ -0,0 +1,230 @@
+import { useEditor } from "@/hooks/use-editor";
+import { clamp } from "@/utils/math";
+import { NumberField } from "@/components/ui/number-field";
+import { OcCheckerboardIcon } from "@opencut/ui/icons";
+import { Fragment, useRef } from "react";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { BlendMode } from "@/lib/rendering";
+import type { ElementType } from "@/lib/timeline";
+import type { ElementAnimations } from "@/lib/animation/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { RainDropIcon } from "@hugeicons/core-free-icons";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { resolveOpacityAtTime } from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { isPropertyAtDefault } from "./transform-tab";
+
+type BlendingElement = {
+ id: string;
+ opacity: number;
+ type: ElementType;
+ blendMode?: BlendMode;
+ startTime: number;
+ duration: number;
+ animations?: ElementAnimations;
+};
+
+const BLEND_MODE_GROUPS = [
+ [{ value: "normal", label: "Normal" }],
+ [
+ { value: "darken", label: "Darken" },
+ { value: "multiply", label: "Multiply" },
+ { value: "color-burn", label: "Color Burn" },
+ ],
+ [
+ { value: "lighten", label: "Lighten" },
+ { value: "screen", label: "Screen" },
+ { value: "plus-lighter", label: "Plus Lighter" },
+ { value: "color-dodge", label: "Color Dodge" },
+ ],
+ [
+ { value: "overlay", label: "Overlay" },
+ { value: "soft-light", label: "Soft Light" },
+ { value: "hard-light", label: "Hard Light" },
+ ],
+ [
+ { value: "difference", label: "Difference" },
+ { value: "exclusion", label: "Exclusion" },
+ ],
+ [
+ { value: "hue", label: "Hue" },
+ { value: "saturation", label: "Saturation" },
+ { value: "color", label: "Color" },
+ { value: "luminosity", label: "Luminosity" },
+ ],
+];
+
+export function BlendingTab({
+ element,
+ trackId,
+}: {
+ element: BlendingElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
+ const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
+ const committedBlendModeRef = useRef(blendMode);
+ if (!isPreviewActive) {
+ committedBlendModeRef.current = blendMode;
+ }
+
+ const {
+ onPointerLeave,
+ onOpenChange: handleBlendModeOpenChange,
+ markCommitted,
+ } = useMenuPreview();
+
+ const previewBlendMode = ({ value }: { value: BlendMode }) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { blendMode: value } },
+ ],
+ });
+
+ const commitBlendMode = (value: string) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { blendMode: value as BlendMode },
+ },
+ ],
+ });
+ }
+ markCommitted();
+ };
+
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedOpacity = resolveOpacityAtTime({
+ baseOpacity: element.opacity,
+ animations: element.animations,
+ localTime,
+ });
+
+ const opacity = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "opacity",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOpacity * 100).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min: 0, max: 100 }) / 100;
+ },
+ valueAtPlayhead: resolvedOpacity,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({ opacity: value }),
+ });
+
+ return (
+
+
+ Blending
+
+
+
+
+ }
+ >
+
+ }
+ value={opacity.displayValue}
+ min={0}
+ max={100}
+ onFocus={opacity.onFocus}
+ onChange={opacity.onChange}
+ onBlur={opacity.onBlur}
+ onScrub={opacity.scrubTo}
+ onScrubEnd={opacity.commitScrub}
+ onReset={() =>
+ opacity.commitValue({ value: DEFAULTS.element.opacity })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOpacity,
+ staticValue: element.opacity,
+ defaultValue: DEFAULTS.element.opacity,
+ })}
+ dragSensitivity="slow"
+ />
+
+
+
+ }
+ className="w-full"
+ >
+
+
+
+ {BLEND_MODE_GROUPS.map((group, groupIndex) => (
+
+ {group.map((option) => (
+
+ previewBlendMode({ value: option.value as BlendMode })
+ }
+ >
+ {option.label}
+
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
new file mode 100644
index 00000000..1b94ee2d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import { useState } from "react";
+import type { ParamValues } from "@/lib/params";
+import type { Effect } from "@/lib/effects/types";
+import type { EffectElement, VisualElement } from "@/lib/timeline";
+import { effectsRegistry } from "@/lib/effects";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ MagicWand05Icon,
+} from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { Separator } from "@/components/ui/separator";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+
+export function StandaloneEffectTab({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effect: Effect = {
+ id: element.id,
+ type: element.effectType,
+ params: element.params,
+ enabled: true,
+ };
+
+ const previewParam = (key: string) => (value: number | string | boolean) => {
+ previewUpdates({
+ params: { ...(renderElement as EffectElement).params, [key]: value },
+ });
+ };
+
+ return (
+
+ );
+}
+
+export function ClipEffectsTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const [dragIndex, setDragIndex] = useState(null);
+ const [dropIndex, setDropIndex] = useState(null);
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effects: Effect[] = element.effects ?? [];
+
+ const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
+ return (
+ (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
+ ?.params ??
+ effects.find((ef) => ef.id === effectId)?.params ??
+ {}
+ );
+ };
+
+ const buildPreviewParam =
+ (effectId: string) =>
+ (key: string) =>
+ (value: number | string | boolean) => {
+ const updatedEffects = (
+ (renderElement as VisualElement).effects ?? []
+ ).map((existing) =>
+ existing.id !== effectId
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ previewUpdates({ effects: updatedEffects });
+ };
+
+ const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
+
+ const handleDragOver = ({
+ event,
+ index,
+ }: {
+ event: React.DragEvent;
+ index: number;
+ }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+
+
+ Effects
+
+ {effects.length === 0 ? (
+
+ ) : (
+
+ {effects.map((effect, index) => {
+ const resolvedDragIndex = dragIndex ?? -1;
+ const isDragging = dragIndex === index;
+ const isDropTarget =
+ dropIndex === index && dragIndex !== null && dragIndex !== index;
+ const showTopDropIndicator =
+ isDropTarget && index < resolvedDragIndex;
+ const showBottomDropIndicator =
+ isDropTarget && index > resolvedDragIndex;
+
+ return (
+ handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group list-none",
+ isDragging && "opacity-40",
+ showTopDropIndicator && "border-t-2 border-primary",
+ showBottomDropIndicator && "border-b-2 border-primary",
+ )}
+ >
+
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ onRemove={() =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ />
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function EmptyView() {
+ const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
+
+ return (
+
+
+
+
No effects
+
+ Add effects to this layer from the Assets panel.
+
+
+
setActiveTab("effects")}
+ >
+ Open effects
+
+
+ );
+}
+
+function EffectSection({
+ effect,
+ renderParams,
+ previewParam,
+ onCommit,
+ onToggle,
+ onRemove,
+}: {
+ effect: Effect;
+ renderParams: ParamValues;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+ onToggle?: () => void;
+ onRemove?: () => void;
+}) {
+ const definition = effectsRegistry.get(effect.type);
+
+ return (
+
+ )
+ }
+ >
+
+ {definition.name}
+
+
+
+
+ {definition.params.map((param) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
new file mode 100644
index 00000000..22dae8cc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "../hooks/use-keyframed-param-property";
+import { resolveGraphicParamsAtTime } from "@/lib/animation";
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+import type { GraphicElement } from "@/lib/timeline";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+
+
+
+ {definition.name}
+
+
+
+ {shapeParams.map((param) => (
+
+ ))}
+
+
+
+ {hasStrokeParams && (
+
+ )}
+
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+
+ {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+
+
+ }
+ >
+ Stroke
+
+
+
+ {strokeParams.map((param) => (
+
+ ))}
+
+
+
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
new file mode 100644
index 00000000..6a5d0d0b
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
@@ -0,0 +1,660 @@
+"use client";
+
+import type { MaskableElement } from "@/lib/timeline";
+import type { Mask, MaskType } from "@/lib/masks/types";
+import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
+import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ FeatherIcon,
+ PlusSignIcon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { ColorPicker } from "@/components/ui/color-picker";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { NumberField } from "@/components/ui/number-field";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
+import { cn } from "@/utils/ui";
+
+type MasksTabProps = {
+ element: MaskableElement;
+ trackId: string;
+};
+
+type MaskItemProps = {
+ trackId: string;
+ elementId: string;
+ mask: Mask;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+};
+
+type EmptyViewProps = {
+ onAddMask: () => void;
+};
+
+type PreviewParamHandler = (
+ key: string,
+) => (value: number | string | boolean) => void;
+
+type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
+
+export function MasksTab({ element, trackId }: MasksTabProps) {
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } =
+ useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+ const maskDefs = masksRegistry.getAll();
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const masks = element.masks ?? [];
+ const renderMasks = renderElement.masks ?? masks;
+ const hasMask = masks.length > 0;
+ const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const elementBounds = useMemo(() => {
+ const clampedTime = Math.min(
+ Math.max(currentTime, element.startTime),
+ element.startTime + element.duration - TIME_EPSILON_SECONDS,
+ );
+
+ return (
+ getVisibleElementsWithBounds({
+ tracks,
+ currentTime: clampedTime,
+ canvasSize,
+ mediaAssets,
+ }).find(
+ (item) => item.trackId === trackId && item.elementId === element.id,
+ )?.bounds ?? null
+ );
+ }, [
+ canvasSize,
+ currentTime,
+ element.duration,
+ element.id,
+ element.startTime,
+ mediaAssets,
+ trackId,
+ tracks,
+ ]);
+
+ const handleDropdownOpenChange = (open: boolean) => {
+ if (hasMask && open) return;
+ setIsDropdownOpen(open);
+ onOpenChange(open);
+ };
+
+ const previewMask = ({ maskType }: { maskType: MaskType }) => {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial,
+ },
+ ],
+ });
+ };
+
+ const commitMask = ({ maskType }: { maskType: MaskType }) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial,
+ },
+ ],
+ });
+ }
+ markCommitted();
+ setIsDropdownOpen(false);
+ };
+
+ const previewMaskParam =
+ ({ index, key }: { index: number; key: string }) =>
+ (value: number | string | boolean) => {
+ if (!renderMasks[index]) {
+ return;
+ }
+
+ const updatedMasks = renderMasks.map((existingMask, maskIndex) =>
+ maskIndex !== index
+ ? existingMask
+ : {
+ ...existingMask,
+ params: {
+ ...existingMask.params,
+ [key]: value,
+ },
+ },
+ );
+
+ previewUpdates({ masks: updatedMasks } as Partial);
+ };
+
+ return (
+
+
+ Masks
+
+ {hasMask ? (
+
+
+
+
+
+
+
+
+
+ Only one mask is supported right now. If you need more,
+ duplicate the clip and apply a different mask to each copy.
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+ {maskDefs.map((definition) => (
+
+ previewMask({ maskType: definition.type })
+ }
+ onClick={() => commitMask({ maskType: definition.type })}
+ >
+
+ {definition.name}
+
+ ))}
+
+
+
+
+ {masks.length === 0 ? (
+
setIsDropdownOpen(true)} />
+ ) : (
+ masks.map((mask, index) => (
+
+ previewMaskParam({ index, key: paramKey })
+ }
+ onCommit={commit}
+ />
+ ))
+ )}
+
+ );
+}
+
+function MaskItem({
+ trackId,
+ elementId,
+ mask,
+ previewParam,
+ onCommit,
+}: MaskItemProps) {
+ const editor = useEditor();
+ const definition = masksRegistry.get(mask.type);
+
+ return (
+
+
+
+ editor.timeline.toggleMaskInverted({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+
+
+
+ editor.timeline.removeMask({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+
+
+
+ }
+ >
+
+
+
+ {definition.name}
+
+
+
+
+
+
+
+ );
+}
+
+function MaskParamsFields({
+ mask,
+ definition,
+ previewParam,
+ onCommit,
+}: {
+ mask: Mask;
+ definition: RegisteredMaskDefinition;
+ previewParam: PreviewParamHandler;
+ onCommit: () => void;
+}) {
+ const featherParam = getNumberParamDefinition({
+ definition,
+ key: "feather",
+ });
+ const strokeWidthParam = getNumberParamDefinition({
+ definition,
+ key: "strokeWidth",
+ });
+ const previewNumberParam = (key: string) => (value: number) =>
+ previewParam(key)(value);
+ const previewStrokeColor = previewParam("strokeColor");
+ const strokeAlignParam = definition.params.find(
+ (param): param is SelectParamDefinition =>
+ param.key === "strokeAlign" && param.type === "select",
+ );
+
+ return (
+
+ {definition.features.hasPosition &&
+ "centerX" in mask.params &&
+ "centerY" in mask.params && (
+
+
+
+
+
+
+ )}
+
+ {definition.features.sizeMode === "width-height" &&
+ "width" in mask.params &&
+ "height" in mask.params && (
+
+
+
+
+
+
+ )}
+
+ {definition.features.sizeMode === "uniform" && "scale" in mask.params && (
+
+
+
+ )}
+
+ {definition.features.hasRotation && "rotation" in mask.params && (
+
+ }
+ param={getNumberParamDefinition({
+ definition,
+ key: "rotation",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "rotation",
+ })}
+ onPreview={previewNumberParam("rotation")}
+ onCommit={onCommit}
+ />
+
+ )}
+
+
+ }
+ param={featherParam}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "feather",
+ })}
+ onPreview={previewNumberParam("feather")}
+ onCommit={onCommit}
+ />
+
+
+
+
+
+
+ previewStrokeColor(`#${color}`)}
+ onChangeEnd={(color) => {
+ previewStrokeColor(`#${color}`);
+ onCommit();
+ }}
+ />
+
+ {strokeAlignParam ? (
+
{
+ previewParam("strokeAlign")(value);
+ onCommit();
+ }}
+ >
+
+
+
+
+ {strokeAlignParam.options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ );
+}
+
+function getNumberParamDefinition({
+ definition,
+ key,
+}: {
+ definition: RegisteredMaskDefinition;
+ key: string;
+}): NumberParamDefinition {
+ const param = definition.params.find((candidate) => candidate.key === key);
+
+ if (!param || param.type !== "number") {
+ throw new Error(`Missing number param definition for mask key "${key}"`);
+ }
+
+ return param;
+}
+
+function getMaskNumber({
+ params,
+ key,
+}: {
+ params: Mask["params"];
+ key: string;
+}): number {
+ const value = params[key];
+
+ if (typeof value !== "number") {
+ throw new Error(`Expected numeric mask param for "${key}"`);
+ }
+
+ return value;
+}
+
+function MaskNumberField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+ icon,
+ className,
+}: {
+ param: NumberParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+ icon?: React.ReactNode;
+ className?: string;
+}) {
+ const isPercent = param.unit === "percent";
+ const percentMax = param.max ?? 100;
+ const displayMultiplier = isPercent
+ ? 100 / percentMax
+ : (param.displayMultiplier ?? 1);
+ const min = isPercent ? 0 : param.min;
+ const max = isPercent ? 100 : param.max;
+ const step = isPercent ? 1 : param.step;
+ const displayValue = value * displayMultiplier;
+ const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
+
+ const clampDisplay = (nextDisplayValue: number) =>
+ max !== undefined
+ ? clamp({ value: nextDisplayValue, min, max })
+ : Math.max(min, nextDisplayValue);
+
+ const previewFromDisplay = (nextDisplayValue: number) => {
+ onPreview(
+ clampDisplay(snapToStep({ value: nextDisplayValue, step })) /
+ displayMultiplier,
+ );
+ };
+
+ const draft = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: displayValue,
+ maxFractionDigits,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
+ },
+ onPreview,
+ onCommit,
+ });
+
+ return (
+
+ );
+}
+
+function EmptyView({ onAddMask }: EmptyViewProps) {
+ return (
+
+
+
+
No masks
+
+ Add a mask to hide or reveal parts of this layer.
+
+
+
+ Add mask
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
new file mode 100644
index 00000000..44f5dd74
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
@@ -0,0 +1,137 @@
+import { useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
+import { buildConstantRetime } from "@/lib/retime";
+import {
+ DEFAULT_RETIME_RATE,
+ MIN_RETIME_RATE,
+ MAX_RETIME_RATE,
+ clampRetimeRate,
+ canMaintainPitch,
+} from "@/constants/retime-constants";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+
+function rateToDisplay({ rate }: { rate: number }): string {
+ return rate.toFixed(2);
+}
+
+function parseSpeedInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ const rounded = Math.round(parsed * 100) / 100;
+ return clampRetimeRate({ rate: rounded });
+}
+
+function buildRetime({
+ rate,
+ maintainPitch,
+}: {
+ rate: number;
+ maintainPitch: boolean;
+}) {
+ if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined;
+ return buildConstantRetime({ rate, maintainPitch });
+}
+
+export function SpeedTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const rate = clampRetimeRate({
+ rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
+ });
+ const maintainPitchAvailable = canMaintainPitch({ rate });
+ const maintainPitch = element.retime?.maintainPitch ?? false;
+ const pendingRateRef = useRef(rate);
+
+ const applyRetime = ({
+ rate: nextRate,
+ maintainPitch: nextMaintainPitch,
+ pushHistory = true,
+ }: {
+ rate: number;
+ maintainPitch: boolean;
+ pushHistory?: boolean;
+ }) => {
+ editor.timeline.updateElementRetime({
+ trackId,
+ elementId: element.id,
+ retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
+ pushHistory,
+ });
+ };
+
+ const speedDraft = usePropertyDraft({
+ displayValue: rateToDisplay({ rate }),
+ parse: (input) => parseSpeedInput({ input }),
+ onPreview: (nextRate) => {
+ pendingRateRef.current = nextRate;
+ applyRetime({ rate: nextRate, maintainPitch, pushHistory: false });
+ },
+ onCommit: () => {
+ applyRetime({ rate: pendingRateRef.current, maintainPitch });
+ },
+ });
+
+ return (
+
+
+ Speed
+
+
+
+
+ }
+ value={speedDraft.displayValue}
+ suffix="x"
+ scrubRanges={[
+ { from: 0.01, to: 1, pixelsPerUnit: 160 },
+ { from: 1, to: 5, pixelsPerUnit: 48 },
+ ]}
+ scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }}
+ onFocus={() => {
+ pendingRateRef.current = rate;
+ speedDraft.onFocus();
+ }}
+ onChange={speedDraft.onChange}
+ onBlur={speedDraft.onBlur}
+ onScrub={speedDraft.scrubTo}
+ onScrubEnd={speedDraft.commitScrub}
+ onReset={() =>
+ applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
+ }
+ isDefault={rate === DEFAULT_RETIME_RATE}
+ />
+
+
+ Change pitch
+
+ applyRetime({ rate, maintainPitch: !checked })
+ }
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
new file mode 100644
index 00000000..f8e2527a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
@@ -0,0 +1,762 @@
+"use client";
+
+import { Textarea } from "@/components/ui/textarea";
+import { FontPicker } from "@/components/ui/font-picker";
+import type { TextElement } from "@/lib/timeline";
+import { NumberField } from "@/components/ui/number-field";
+import { useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
+import { uppercase } from "@/utils/string";
+import { clamp } from "@/utils/math";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_COLOR } from "@/constants/project-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+ MAX_FONT_SIZE,
+ MIN_FONT_SIZE,
+} from "@/constants/text-constants";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { isPropertyAtDefault } from "./transform-tab";
+import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ MinusSignIcon,
+ PlusSignIcon,
+ TextFontIcon,
+} from "@hugeicons/core-free-icons";
+import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { cn } from "@/utils/ui";
+
+export function TextTab({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function ContentSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const content = usePropertyDraft({
+ displayValue: element.content,
+ parse: (input) => input,
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { content: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+
+ );
+}
+
+function TypographySection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTextColor = resolveColorAtTime({
+ baseColor: element.color,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ });
+
+ const textColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedTextColor,
+ buildBaseUpdates: ({ value }) => ({ color: value }),
+ });
+
+ const fontSize = usePropertyDraft({
+ displayValue: element.fontSize.toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: MIN_FONT_SIZE,
+ max: MAX_FONT_SIZE,
+ });
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { fontSize: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+
+
+ Typography
+
+
+
+
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontFamily: value },
+ },
+ ],
+ })
+ }
+ />
+
+
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontSize: DEFAULTS.text.element.fontSize,
+ },
+ },
+ ],
+ })
+ }
+ isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
+ icon={ }
+ />
+
+
+ }
+ >
+ textColor.onChange({ color: `#${color}` })}
+ onChangeEnd={textColor.onChangeEnd}
+ />
+
+
+
+
+ );
+}
+
+function SpacingSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const letterSpacing = usePropertyDraft({
+ displayValue: Math.round(
+ element.letterSpacing ?? DEFAULTS.text.letterSpacing,
+ ).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: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed)
+ ? null
+ : Math.max(0.1, Math.round(parsed * 10) / 10);
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { lineHeight: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+
+
+ Spacing
+
+
+
+
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { letterSpacing: DEFAULTS.text.letterSpacing },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
+ DEFAULTS.text.letterSpacing
+ }
+ icon={ }
+ />
+
+
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { lineHeight: DEFAULTS.text.lineHeight },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
+ DEFAULTS.text.lineHeight
+ }
+ icon={ }
+ />
+
+
+
+
+ );
+}
+
+function BackgroundSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedBgColor = resolveColorAtTime({
+ baseColor: element.background.color,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ });
+
+ const bgColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedBgColor,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...element.background, color: value },
+ }),
+ });
+
+ const bg = element.background;
+
+ const resolvedPaddingX = resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ });
+ const resolvedPaddingY = resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ });
+ const resolvedOffsetX = resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ });
+ const resolvedOffsetY = resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ });
+ const resolvedCornerRadius = resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ });
+
+ const paddingX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ },
+ valueAtPlayhead: resolvedPaddingX,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingX: value },
+ }),
+ });
+
+ const paddingY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ },
+ valueAtPlayhead: resolvedPaddingY,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingY: value },
+ }),
+ });
+
+ const offsetX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ valueAtPlayhead: resolvedOffsetX,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetX: value },
+ }),
+ });
+
+ const offsetY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ valueAtPlayhead: resolvedOffsetY,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetY: value },
+ }),
+ });
+
+ const cornerRadius = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedCornerRadius).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedCornerRadius,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, cornerRadius: value },
+ }),
+ });
+
+ const toggleBackgroundEnabled = () => {
+ const enabled = !element.background.enabled;
+ const color =
+ enabled && element.background.color === "transparent"
+ ? lastSelectedColor.current
+ : element.background.color;
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: {
+ ...element.background,
+ enabled,
+ color,
+ },
+ },
+ },
+ ],
+ });
+ };
+
+ return (
+
+ {
+ event.stopPropagation();
+ toggleBackgroundEnabled();
+ }}
+ >
+
+
+ }
+ >
+ Background
+
+
+
+
+ }
+ >
+ {
+ const hexColor = `#${color}`;
+ if (color !== "transparent") {
+ lastSelectedColor.current = hexColor;
+ }
+ bgColor.onChange({ color: hexColor });
+ }}
+ onChangeEnd={bgColor.onChangeEnd}
+ />
+
+
+
+ }
+ >
+
+ paddingX.commitValue({
+ value: DEFAULTS.text.background.paddingX,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingX,
+ staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ defaultValue: DEFAULTS.text.background.paddingX,
+ })}
+ />
+
+
+ }
+ >
+
+ paddingY.commitValue({
+ value: DEFAULTS.text.background.paddingY,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingY,
+ staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ defaultValue: DEFAULTS.text.background.paddingY,
+ })}
+ />
+
+
+
+
+ }
+ >
+
+ offsetX.commitValue({
+ value: DEFAULTS.text.background.offsetX,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetX,
+ staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ defaultValue: DEFAULTS.text.background.offsetX,
+ })}
+ />
+
+
+ }
+ >
+
+ offsetY.commitValue({
+ value: DEFAULTS.text.background.offsetY,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetY,
+ staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ defaultValue: DEFAULTS.text.background.offsetY,
+ })}
+ />
+
+
+
+ }
+ >
+
+ cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedCornerRadius,
+ staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ defaultValue: CORNER_RADIUS_MIN,
+ })}
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
new file mode 100644
index 00000000..a015a9bb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
@@ -0,0 +1,424 @@
+import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { VisualElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Link05Icon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
+import { usePropertiesStore } from "../stores/properties-store";
+
+export function parseNumericInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export 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 TransformTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
+ const setTransformScaleLocked = usePropertiesStore(
+ (s) => s.setTransformScaleLocked,
+ );
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const position = useKeyframedVectorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position,
+ displayX: Math.round(resolvedTransform.position.x).toString(),
+ displayY: Math.round(resolvedTransform.position.y).toString(),
+ parseComponent: (input) => parseNumericInput({ input }),
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: { ...element.transform, position: value },
+ }),
+ });
+
+ const parseScale = (input: string) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return parsed / 100;
+ };
+
+ const scaleX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleX,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleX: value,
+ ...(isScaleLocked ? { scaleY: value } : {}),
+ },
+ }),
+ });
+
+ const scaleY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleY,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleY: value,
+ ...(isScaleLocked ? { scaleX: value } : {}),
+ },
+ }),
+ });
+
+ const scaleFieldPropsX = {
+ value: scaleX.displayValue,
+ onFocus: scaleX.onFocus,
+ onChange: scaleX.onChange,
+ onBlur: scaleX.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleX.scrubTo,
+ onScrubEnd: scaleX.commitScrub,
+ onReset: () =>
+ scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleX,
+ staticValue: element.transform.scaleX,
+ defaultValue: DEFAULTS.element.transform.scaleX,
+ }),
+ };
+
+ const scaleFieldPropsY = {
+ value: scaleY.displayValue,
+ onFocus: scaleY.onFocus,
+ onChange: scaleY.onChange,
+ onBlur: scaleY.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleY.scrubTo,
+ onScrubEnd: scaleY.commitScrub,
+ onReset: () =>
+ scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleY,
+ staticValue: element.transform.scaleY,
+ defaultValue: DEFAULTS.element.transform.scaleY,
+ }),
+ };
+
+ 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 = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ valueAtPlayhead: resolvedTransform.rotate,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
+ });
+
+ const hasScaleKeyframe = hasGroupKeyframeAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+
+ const toggleScaleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+ const existing = getGroupKeyframesAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+ if (existing.length > 0) {
+ editor.timeline.removeKeyframes({
+ keyframes: existing.map((ref) => ({
+ trackId,
+ elementId: element.id,
+ ...ref,
+ })),
+ });
+ return;
+ }
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleX",
+ time: localTime,
+ value: resolvedTransform.scaleX,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleY",
+ time: localTime,
+ value: resolvedTransform.scaleY,
+ },
+ ],
+ });
+ };
+
+ const scaleLockButton = (
+ setTransformScaleLocked(!isScaleLocked)}
+ >
+
+
+ );
+
+ return (
+
+
+ Transform
+
+
+
+
+ {isScaleLocked ? (
+ <>
+
+ }
+ >
+ }
+ {...scaleFieldPropsX}
+ />
+
+ {scaleLockButton}
+ >
+ ) : (
+ <>
+
+ }
+ >
+
+
+ {scaleLockButton}
+
+ }
+ >
+
+
+ >
+ )}
+
+
+ }
+ >
+
+
+ position.commitX({
+ value: DEFAULTS.element.transform.position.x,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULTS.element.transform.position.x,
+ })}
+ />
+
+ position.commitY({
+ value: DEFAULTS.element.transform.position.y,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULTS.element.transform.position.y,
+ })}
+ />
+
+
+
+
+ }
+ >
+
+ }
+ 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: DEFAULTS.element.transform.rotate,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.rotate,
+ staticValue: element.transform.rotate,
+ defaultValue: DEFAULTS.element.transform.rotate,
+ })}
+ />
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
index bbc5cb83..cbb19bd0 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -1,176 +1,167 @@
-import { useEffect, useRef, useState } from "react";
-import WaveSurfer from "wavesurfer.js";
+"use client";
+import { useCallback, useEffect, useRef } from "react";
+import { useResizeObserver } from "@/hooks/use-resize-observer";
+import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
+import { findScrollParent } from "@/utils/browser";
+import { cn } from "@/utils/ui";
+
+const BAR_WIDTH = 2;
+const BAR_GAP = 1;
+const BAR_STEP = BAR_WIDTH + BAR_GAP;
interface AudioWaveformProps {
audioUrl?: string;
audioBuffer?: AudioBuffer;
- height?: number;
+ color?: string;
className?: string;
}
-function extractPeaks({
- buffer,
- length = 512,
-}: {
- buffer: AudioBuffer;
- length?: number;
-}): number[][] {
- const channels = buffer.numberOfChannels;
- const peaks: number[][] = [];
-
- for (let c = 0; c < channels; c++) {
- const data = buffer.getChannelData(c);
- const step = Math.floor(data.length / length);
- const channelPeaks: number[] = [];
-
- for (let i = 0; i < length; i++) {
- const start = i * step;
- const end = Math.min(start + step, data.length);
- let max = 0;
- for (let j = start; j < end; j++) {
- const abs = Math.abs(data[j]);
- if (abs > max) max = abs;
- }
- channelPeaks.push(max);
- }
- peaks.push(channelPeaks);
- }
-
- return peaks;
-}
-
export function AudioWaveform({
audioUrl,
audioBuffer,
- height = 32,
+ color = "rgba(255, 255, 255, 0.7)",
className = "",
}: AudioWaveformProps) {
- const waveformRef = useRef(null);
- const wavesurfer = useRef(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(false);
+ const canvasRef = useRef(null);
+ const containerRef = useRef(null);
+ const bufferRef = useRef(null);
+ const globalMaxRef = useRef(1);
+ const scrollParentRef = useRef(null);
+ const heightRef = useRef(0);
- useEffect(() => {
- let mounted = true;
- const ws = wavesurfer.current;
+ const drawVisible = useCallback(() => {
+ const container = containerRef.current;
+ const canvas = canvasRef.current;
+ const buffer = bufferRef.current;
+ const height = heightRef.current;
- const initWaveSurfer = async () => {
- if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
+ if (!container || !canvas || !buffer || height <= 0) return;
- try {
- if (ws) {
- wavesurfer.current = null;
- }
+ const elementWidth = container.offsetWidth;
+ if (elementWidth <= 0) return;
- const newWaveSurfer = WaveSurfer.create({
- container: waveformRef.current,
- waveColor: "rgba(255, 255, 255, 0.6)",
- progressColor: "rgba(255, 255, 255, 0.9)",
- cursorColor: "transparent",
- barWidth: 2,
- barGap: 1,
- height,
- normalize: true,
- interact: false,
- });
+ const containerRect = container.getBoundingClientRect();
+ const scrollParent = scrollParentRef.current;
- if (mounted) {
- wavesurfer.current = newWaveSurfer;
- } else {
- try {
- newWaveSurfer.destroy();
- } catch {}
- return;
- }
+ let clipLeft: number;
+ let clipRight: number;
- newWaveSurfer.on("ready", () => {
- if (mounted) {
- setIsLoading(false);
- setError(false);
- }
- });
-
- newWaveSurfer.on("error", (err) => {
- if (mounted) {
- console.error("WaveSurfer error:", err);
- setError(true);
- setIsLoading(false);
- }
- });
-
- if (audioBuffer) {
- const peaks = extractPeaks({ buffer: audioBuffer });
- newWaveSurfer.load("", peaks, audioBuffer.duration);
- } else if (audioUrl) {
- await newWaveSurfer.load(audioUrl);
- }
- } catch (err) {
- if (mounted) {
- console.error("Failed to initialize WaveSurfer:", err);
- setError(true);
- setIsLoading(false);
- }
- }
- };
-
- if (ws) {
- const wsToDestroy = ws;
- wavesurfer.current = null;
-
- requestAnimationFrame(() => {
- try {
- wsToDestroy.destroy();
- } catch {}
- if (mounted) {
- initWaveSurfer();
- }
- });
+ if (scrollParent) {
+ const parentRect = scrollParent.getBoundingClientRect();
+ clipLeft = Math.max(0, parentRect.left - containerRect.left);
+ clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
} else {
- initWaveSurfer();
+ clipLeft = Math.max(0, -containerRect.left);
+ clipRight = Math.min(
+ elementWidth,
+ window.innerWidth - containerRect.left,
+ );
}
- return () => {
- mounted = false;
+ const visibleWidth = clipRight - clipLeft;
+ if (visibleWidth <= 0) return;
- const wsToDestroy = wavesurfer.current;
+ const dpr = window.devicePixelRatio || 1;
+ const canvasW = Math.round(visibleWidth * dpr);
+ const canvasH = Math.round(height * dpr);
- wavesurfer.current = null;
+ if (canvasW <= 0 || canvasH <= 0) return;
- if (wsToDestroy) {
- requestAnimationFrame(() => {
- try {
- wsToDestroy.destroy();
- } catch {}
- });
- }
- };
- }, [audioUrl, audioBuffer, height]);
+ canvas.width = canvasW;
+ canvas.height = canvasH;
+ canvas.style.width = `${visibleWidth}px`;
+ canvas.style.height = `${height}px`;
+ canvas.style.left = `${clipLeft}px`;
- if (error) {
- return (
-
- Audio unavailable
-
+ const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
+ const startFraction = clipLeft / elementWidth;
+ const endFraction = clipRight / elementWidth;
+ const startSample = Math.floor(startFraction * buffer.length);
+ const endSample = Math.min(
+ buffer.length,
+ Math.ceil(endFraction * buffer.length),
);
- }
+
+ const peaks = extractRmsRange({
+ buffer,
+ count: barCount,
+ startSample,
+ endSample,
+ globalMax: globalMaxRef.current,
+ });
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ ctx.clearRect(0, 0, canvasW, canvasH);
+ ctx.scale(dpr, dpr);
+ ctx.fillStyle = color;
+
+ const maxBarHeight = height * 0.7;
+
+ for (let i = 0; i < barCount; i++) {
+ const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
+ const barH = Math.max(1, scaled * maxBarHeight);
+ ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
+ }
+ }, [color]);
+
+ useEffect(() => {
+ let isCancelled = false;
+
+ async function load() {
+ let buffer = audioBuffer ?? null;
+
+ if (!buffer && audioUrl) {
+ try {
+ const resp = await fetch(audioUrl);
+ const arrayBuffer = await resp.arrayBuffer();
+ const actx = new AudioContext();
+ buffer = await actx.decodeAudioData(arrayBuffer);
+ actx.close();
+ } catch {
+ return;
+ }
+ }
+
+ if (!buffer || isCancelled) return;
+
+ bufferRef.current = buffer;
+ globalMaxRef.current = computeGlobalMaxRms({ buffer });
+ drawVisible();
+ }
+
+ load();
+ return () => {
+ isCancelled = true;
+ };
+ }, [audioUrl, audioBuffer, drawVisible]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ scrollParentRef.current = findScrollParent({ element: container });
+ const scrollParent = scrollParentRef.current;
+ if (!scrollParent) return;
+
+ scrollParent.addEventListener("scroll", drawVisible, { passive: true });
+ return () => scrollParent.removeEventListener("scroll", drawVisible);
+ }, [drawVisible]);
+
+ const onResize = useCallback(
+ (entry: ResizeObserverEntry) => {
+ heightRef.current = entry.contentRect.height;
+ drawVisible();
+ },
+ [drawVisible],
+ );
+
+ useResizeObserver({ ref: containerRef, onResize });
return (
-
- {isLoading && (
-
- Loading...
-
- )}
-
+
+
);
}
-
-export default AudioWaveform;
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index 864bb557..7000f98b 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -5,7 +5,10 @@ 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 } from "@/constants/timeline-constants";
+import {
+ DEFAULT_BOOKMARK_COLOR,
+ TIMELINE_BOOKMARK_ROW_HEIGHT,
+} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import {
@@ -13,7 +16,7 @@ import {
Delete02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import type { Bookmark } from "@/types/timeline";
+import type { Bookmark } from "@/lib/timeline";
import {
Popover,
PopoverAnchor,
@@ -25,7 +28,10 @@ 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";
+import {
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
@@ -72,14 +78,17 @@ export function TimelineBookmarksRow({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineBookmarksRowProps) {
- const editor = useEditor();
- const activeScene = editor.scenes.getActiveScene();
+ const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks);
return (
-
+
- {activeScene.bookmarks.map((bookmark) => (
+ {bookmarks.map((bookmark) => (
);
}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 593976a8..f1ecd9f4 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -3,31 +3,44 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Delete02Icon,
+ MagicWand05Icon,
+ MusicNote03Icon,
TaskAdd02Icon,
+ TextIcon,
ViewIcon,
ViewOffSlashIcon,
VolumeHighIcon,
VolumeOffIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
-} from "../../../ui/context-menu";
+} from "@/components/ui/context-menu";
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
-import { useState, useRef, useCallback } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import type { ElementDragState, DropTarget } from "@/lib/timeline";
import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead";
-import { SelectionBox } from "../../selection-box";
-import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
+import { SelectionBox } from "@/lib/selection/selection-box";
+import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import {
TIMELINE_CONSTANTS,
- TRACK_CONFIG,
+ TRACK_GAP,
+ TIMELINE_SCROLLBAR_SIZE_PX,
} from "@/constants/timeline-constants";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
@@ -36,12 +49,11 @@ import {
getTotalTracksHeight,
canTracktHaveAudio,
canTrackBeHidden,
+ isMainTrack,
getTimelineZoomMin,
getTimelinePaddingPx,
- isMainTrack,
} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
-import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
@@ -49,27 +61,58 @@ import { TimelineRuler } from "./timeline-ruler";
import { TimelineBookmarksRow } from "./bookmarks";
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
+import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing";
+import { cn } from "@/utils/ui";
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
const FALLBACK_CONTAINER_WIDTH = 1000;
+const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
+const TRACK_ICONS: Record = {
+ video: ,
+ text: (
+
+ ),
+ audio: (
+
+ ),
+ graphic: ,
+ effect: (
+
+ ),
+};
export function Timeline() {
- const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
- const { clearElementSelection, setElementSelection } = useElementSelection();
+ const {
+ selectedElements,
+ clearElementSelection,
+ setElementSelection,
+ mergeElementsIntoSelection,
+ } = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
- const tracks = timeline.getTracks();
+ const tracks = useEditor((editor) => editor.timeline.getTracks());
const seek = (time: number) => editor.playback.seek({ time });
const timelineRef = useRef(null);
const timelineHeaderRef = useRef(null);
const rulerRef = useRef(null);
+ const rulerScrollRef = useRef(null);
const tracksContainerRef = useRef(null);
const tracksScrollRef = useRef(null);
const trackLabelsRef = useRef(null);
@@ -110,9 +153,110 @@ export function Timeline() {
initialScrollLeft: savedViewState?.scrollLeft,
initialPlayheadTime: savedViewState?.playheadTime,
tracksScrollRef,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
});
+ // Stable refs so the wheel listener never goes stale
+ const setZoomLevelRef = useRef(setZoomLevel);
+ useEffect(() => {
+ setZoomLevelRef.current = setZoomLevel;
+ }, [setZoomLevel]);
+
+ const saveScrollPositionRef = useRef(saveScrollPosition);
+ useEffect(() => {
+ saveScrollPositionRef.current = saveScrollPosition;
+ }, [saveScrollPosition]);
+
+ const minZoomLevelRef = useRef(minZoomLevel);
+ useEffect(() => {
+ minZoomLevelRef.current = minZoomLevel;
+ }, [minZoomLevel]);
+
+ // Pushes tracks scroll position to the two overflow:hidden followers
+ // (ruler and track labels). Called from the wheel handler (before paint,
+ // zero lag) and from onScroll on the tracks area (covers scrollbar drag).
+ const syncFollowers = useCallback(() => {
+ const tracks = tracksScrollRef.current;
+ if (!tracks) return;
+ if (rulerScrollRef.current) {
+ rulerScrollRef.current.scrollLeft = tracks.scrollLeft;
+ }
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = tracks.scrollTop;
+ }
+ }, []);
+
+ // Single non-passive capture listener owns all wheel input. Prevents any
+ // native scroll or browser zoom from firing inside the timeline.
+ useEffect(() => {
+ const container = timelineRef.current;
+ if (!container) return;
+
+ let pendingZoomDelta = 0;
+ let zoomRafId: ReturnType | null = null;
+
+ const onWheel = (e: WheelEvent) => {
+ const isZoom = e.ctrlKey || e.metaKey;
+
+ if (isZoom) {
+ e.preventDefault();
+ const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
+ pendingZoomDelta += normalizedDelta;
+
+ if (zoomRafId === null) {
+ zoomRafId = requestAnimationFrame(() => {
+ const frameRawDelta = pendingZoomDelta;
+ const cappedDelta =
+ Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ setZoomLevelRef.current((prev) => prev * zoomFactor);
+ pendingZoomDelta = 0;
+ zoomRafId = null;
+ });
+ }
+ return;
+ }
+
+ const tracks = tracksScrollRef.current;
+ if (!tracks) return;
+
+ const isHorizontal =
+ e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY);
+
+ e.preventDefault();
+
+ if (isHorizontal) {
+ const raw =
+ Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
+ const clamped =
+ Math.sign(raw) *
+ Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX);
+ tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
+ } else {
+ tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
+ }
+
+ syncFollowers();
+ saveScrollPositionRef.current();
+ };
+
+ container.addEventListener("wheel", onWheel, {
+ passive: false,
+ capture: true,
+ });
+ return () => {
+ container.removeEventListener("wheel", onWheel, { capture: true });
+ if (zoomRafId !== null) cancelAnimationFrame(zoomRafId);
+ };
+ }, [syncFollowers]);
+
+ useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll: () => saveScrollPositionRef.current(),
+ isReady: tracks.length > 0,
+ });
+
const {
dragState,
dragDropTarget,
@@ -124,7 +268,6 @@ export function Timeline() {
timelineRef,
tracksContainerRef,
tracksScrollRef,
- headerRef: timelineHeaderRef,
snappingEnabled,
onSnapPointChange: handleSnapPointChange,
});
@@ -144,14 +287,13 @@ export function Timeline() {
useTimelinePlayhead({
zoomLevel,
rulerRef,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
tracksScrollRef,
playheadRef,
});
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
- headerRef: timelineHeaderRef,
tracksScrollRef,
zoomLevel,
});
@@ -161,17 +303,37 @@ export function Timeline() {
handleMouseDown: handleSelectionMouseDown,
isSelecting,
shouldIgnoreClick,
- } = useSelectionBox({
+ } = useBoxSelect({
containerRef: tracksContainerRef,
- headerRef: timelineHeaderRef,
- onSelectionComplete: (elements) => {
- setElementSelection({ elements });
+ selectedIds: selectedElements,
+ anchorId: null,
+ getIsAdditiveSelection: (event) =>
+ event.shiftKey || event.ctrlKey || event.metaKey,
+ resolveIntersections: ({ startPos, currentPos }) => {
+ if (!tracksContainerRef.current) {
+ return [];
+ }
+
+ return resolveTimelineElementIntersections({
+ container: tracksContainerRef.current,
+ scrollContainer: tracksScrollRef.current,
+ tracks,
+ zoomLevel,
+ startPos,
+ currentPos,
+ });
+ },
+ onSelectionChange: ({ intersectedIds, isAdditive }) => {
+ if (isAdditive) {
+ mergeElementsIntoSelection({ elements: intersectedIds });
+ } else {
+ setElementSelection({ elements: intersectedIds });
+ }
},
- tracksScrollRef,
- zoomLevel,
});
- const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
+ const containerWidth =
+ tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({
@@ -183,11 +345,16 @@ export function Timeline() {
contentWidth + paddingPx,
containerWidth,
);
+ const tracksViewportWidth =
+ tracksScrollRef.current?.clientWidth ??
+ tracksContainerRef.current?.clientWidth ??
+ containerWidth;
+ const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth;
useEdgeAutoScroll({
isActive: bookmarkDragState.isDragging,
getMouseClientX: () => bookmarkLastMouseXRef.current,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
tracksScrollRef,
contentWidth: dynamicTimelineWidth,
});
@@ -205,7 +372,7 @@ export function Timeline() {
} = useTimelineSeek({
playheadRef,
trackLabelsRef,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
tracksScrollRef,
zoomLevel,
duration: timeline.getTotalDuration(),
@@ -214,13 +381,9 @@ export function Timeline() {
seek,
});
- useScrollSync({
- tracksScrollRef,
- trackLabelsScrollRef,
- });
-
const timelineHeaderHeight =
- timelineHeaderRef.current?.getBoundingClientRect().height ?? 0;
+ (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
+ TIMELINE_CONSTANTS.PADDING_TOP_PX || 0;
return (
setZoomLevel(zoom)}
/>
-
+
+
+
+
+
+
+
+
+
+
+
{
+ syncFollowers();
+ saveScrollPosition();
+ }}
+ >
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
+
{
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksClick(event);
+ }}
+ >
+ {tracks.length > 0 && (
+ {
+ handleSelectionMouseDown(event);
+ handleTracksMouseDown(event);
+ }}
+ onTrackMouseUp={handleTracksClick}
+ shouldIgnoreClick={shouldIgnoreClick}
+ isDragOver={isDragOver}
+ dropTarget={dropTarget}
+ />
+ )}
+
+
{
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={handleTracksClick}
+ />
+
+
+
+
+
-
-
-
- .
-
-
- .
-
- {tracks.length > 0 && (
-
-
-
- {tracks.map((track) => (
-
-
- {process.env.NODE_ENV === "development" &&
- isMainTrack(track) && (
-
- )}
- {canTracktHaveAudio(track) && (
-
- editor.timeline.toggleTrackMute({
- trackId: track.id,
- })
- }
- />
- )}
- {canTrackBeHidden(track) && (
-
- editor.timeline.toggleTrackVisibility({
- trackId: track.id,
- })
- }
- />
- )}
-
-
-
- ))}
-
-
-
- )}
-
-
-
-
-
-
-
{
- const isDirectTarget = event.target === event.currentTarget;
- if (!isDirectTarget) return;
- event.stopPropagation();
- handleTracksMouseDown(event);
- handleSelectionMouseDown(event);
- }}
- onClick={(event) => {
- const isDirectTarget = event.target === event.currentTarget;
- if (!isDirectTarget) return;
- event.stopPropagation();
- handleTracksClick(event);
- }}
- onWheel={(event) => {
- if (
- event.shiftKey ||
- Math.abs(event.deltaX) > Math.abs(event.deltaY)
- ) {
- return;
- }
- handleWheel(event);
- }}
- onScroll={() => {
- saveScrollPosition();
- }}
- >
-
-
-
-
-
-
-
- {tracks.length === 0 ? (
-
- ) : (
- [...tracks]
- .map((track, index) => ({ track, index }))
- .sort((a, b) => {
- const aHasDragged = a.track.elements.some(
- (element) => element.id === dragState.elementId,
- );
- const bHasDragged = b.track.elements.some(
- (element) => element.id === dragState.elementId,
- );
- if (aHasDragged) return 1;
- if (bHasDragged) return -1;
- return 0;
- })
- .map(({ track, index }) => (
-
-
-
- {
- handleSelectionMouseDown(event);
- handleTracksMouseDown(event);
- }}
- onTrackClick={handleTracksClick}
- shouldIgnoreClick={shouldIgnoreClick}
- targetElementId={
- isDragOver
- ? dropTarget?.targetElement?.elementId ?? null
- : null
- }
- />
-
-
-
- }
- onClick={(event) => {
- event.stopPropagation();
- invokeAction("paste-copied");
- }}
- >
- Paste elements
-
- {
- event.stopPropagation();
- timeline.toggleTrackMute({
- trackId: track.id,
- });
- }}
- >
-
-
- {canTracktHaveAudio(track) && track.muted
- ? "Unmute track"
- : "Mute track"}
-
-
- {
- event.stopPropagation();
- timeline.toggleTrackVisibility({
- trackId: track.id,
- });
- }}
- >
-
-
- {canTrackBeHidden(track) && track.hidden
- ? "Show track"
- : "Hide track"}
-
-
- {
- event.stopPropagation();
- timeline.removeTrack({
- trackId: track.id,
- });
- }}
- variant="destructive"
- >
-
- Delete track
-
-
-
- ))
- )}
-
-
-
-
-
);
}
+function TrackLabelsPanel({
+ trackLabelsRef,
+ trackLabelsScrollRef,
+ timelineHeaderHeight,
+ hasHorizontalScrollbar,
+}: {
+ trackLabelsRef: React.RefObject
;
+ trackLabelsScrollRef: React.RefObject;
+ timelineHeaderHeight: number;
+ hasHorizontalScrollbar: boolean;
+}) {
+ const editor = useEditor();
+ const tracks = useEditor((e) => e.timeline.getTracks());
+ const { selectedElements } = useElementSelection();
+ const tracksWithSelection = useMemo(
+ () => new Set(selectedElements.map((el) => el.trackId)),
+ [selectedElements],
+ );
+
+ return (
+
+
+
+
+ {tracks.length > 0 && (
+
+ {tracks.map((track) => (
+
+
+ {canTracktHaveAudio(track) && (
+
+ editor.timeline.toggleTrackMute({ trackId: track.id })
+ }
+ />
+ )}
+ {canTrackBeHidden(track) && (
+
+ editor.timeline.toggleTrackVisibility({
+ trackId: track.id,
+ })
+ }
+ />
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+function TimelineTrackRows({
+ dragElementId,
+ zoomLevel,
+ dragState,
+ tracksScrollRef,
+ lastMouseXRef,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ onTrackMouseUp,
+ shouldIgnoreClick,
+ isDragOver,
+ dropTarget,
+}: {
+ dragElementId: string | null;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ tracksScrollRef: React.RefObject;
+ lastMouseXRef: React.RefObject;
+ onSnapPointChange: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onElementMouseDown"];
+ onElementClick: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onElementClick"];
+ onTrackMouseDown: (event: React.MouseEvent) => void;
+ onTrackMouseUp: (event: React.MouseEvent) => void;
+ shouldIgnoreClick: () => boolean;
+ isDragOver: boolean;
+ dropTarget: DropTarget | null;
+}) {
+ const timeline = useEditor((e) => e.timeline);
+ const tracks = useEditor((e) => e.timeline.getTracks());
+ const { selectedElements } = useElementSelection();
+ const tracksWithSelection = useMemo(
+ () => new Set(selectedElements.map((el) => el.trackId)),
+ [selectedElements],
+ );
+
+ const sortedTracks = useMemo(
+ () =>
+ [...tracks]
+ .map((track, index) => ({ track, index }))
+ .sort((a, b) => {
+ const aHasDragged = a.track.elements.some(
+ (el) => el.id === dragElementId,
+ );
+ const bHasDragged = b.track.elements.some(
+ (el) => el.id === dragElementId,
+ );
+ if (aHasDragged) return 1;
+ if (bHasDragged) return -1;
+ return 0;
+ }),
+ [tracks, dragElementId],
+ );
+
+ return (
+ <>
+ {sortedTracks.map(({ track, index }) => (
+
+
+
+
+
+
+
+ }
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+
+ }
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackMute({ trackId: track.id });
+ }}
+ >
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+
+ }
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackVisibility({ trackId: track.id });
+ }}
+ >
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+
+ {!isMainTrack(track) && (
+ }
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.removeTrack({ trackId: track.id });
+ }}
+ variant="destructive"
+ >
+ Delete track
+
+ )}
+
+
+ ))}
+ >
+ );
+}
+
+function TimelineGutter({
+ onMouseDown,
+ onClick,
+}: {
+ onMouseDown: (event: React.MouseEvent) => void;
+ onClick: (event: React.MouseEvent) => void;
+}) {
+ // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system
+ // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system
+ return
;
+}
+
function TrackIcon({ track }: { track: TimelineTrack }) {
- return <>{TRACK_CONFIG[track.type].icon}>;
+ return <>{TRACK_ICONS[track.type]}>;
}
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 927d336a..acb57203 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -6,15 +6,12 @@ import {
getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
} from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
-
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
interface SnapIndicatorProps {
snapPoint: SnapPoint | null;
zoomLevel: number;
isVisible: boolean;
- tracks: TimelineTrack[];
timelineRef: React.RefObject;
- trackLabelsRef?: React.RefObject;
tracksScrollRef: React.RefObject;
}
@@ -22,17 +19,13 @@ export function SnapIndicator({
snapPoint,
zoomLevel,
isVisible,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
}: SnapIndicatorProps) {
const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
snapPoint,
zoomLevel,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
});
@@ -48,6 +41,7 @@ export function SnapIndicator({
top: topPosition,
height: `${height}px`,
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.snapIndicator,
}}
>
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 9121f6c2..13a31375 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -2,7 +2,7 @@
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-import AudioWaveform from "./audio-waveform";
+import { AudioWaveform } from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import {
useKeyframeDrag,
@@ -12,14 +12,16 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation";
import {
- getTrackClasses,
+ getElementClasses,
getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
+ hasElementEffects,
hasMediaId,
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
+import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
import {
ContextMenu,
ContextMenuContent,
@@ -30,14 +32,22 @@ import {
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
- VisualElement,
ElementDragState,
-} from "@/types/timeline";
-import type { MediaAsset } from "@/types/assets";
+ VideoElement,
+ ImageElement,
+ AudioElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
+import {
+ getActionDefinition,
+ type TAction,
+ type TActionWithOptionalArgs,
+ invokeAction,
+} from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { resolveStickerId } from "@/lib/stickers";
+import { buildGraphicPreviewUrl } from "@/lib/graphics";
import Image from "next/image";
import {
ScissorIcon,
@@ -56,13 +66,16 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
import type { ComponentProps, ReactNode } from "react";
-import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation";
+import type {
+ SelectedKeyframeRef,
+ ElementKeyframe,
+} from "@/lib/animation/types";
import { cn } from "@/utils/ui";
-import { Button } from "@/components/ui/button";
-import { usePropertiesStore } from "@/stores/properties-store";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5;
+const THUMBNAIL_ASPECT_RATIO = 16 / 9;
interface KeyframeIndicator {
time: number;
@@ -195,16 +208,15 @@ export function TimelineElement({
dragState,
isDropTarget = false,
}: TimelineElementProps) {
- const editor = useEditor();
+ const mediaAssets = useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
- const { requestRevealMedia } = useAssetsPanelStore();
+ const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
let mediaAsset: MediaAsset | null = null;
if (hasMediaId(element)) {
mediaAsset =
- editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
- null;
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
}
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
@@ -394,60 +406,46 @@ function ElementInner({
}) => void;
isDropTarget?: boolean;
}) {
- const opacityClass =
- (canElementBeHidden(element) && element.hidden) || isDropTarget
- ? "opacity-50"
- : "";
- const closeClipEffects = usePropertiesStore(
- (state) => state.closeClipEffects,
- );
-
+ const isReducedOpacity =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget;
return (
-
{
- closeClipEffects();
- onElementClick(event, element);
- }}
- onMouseDown={(event) => onElementMouseDown(event, element)}
+
-
- {element.type !== "audio" && element.type !== "effect" && (
-
-
+
onElementClick(event, element)}
+ onMouseDown={(event) => onElementMouseDown(event, element)}
+ >
+
+
+
+
- )}
+
{isSelected && (
<>
@@ -535,8 +533,8 @@ function KeyframeIndicators({
const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
isKeyframeSelected({ keyframe }),
);
- const isBeingDragged = indicator.keyframes.some((kf) =>
- dragState.draggingKeyframeIds.has(kf.keyframeId),
+ const isBeingDragged = indicator.keyframes.some((keyframe) =>
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
);
const visualOffsetPx = getVisualOffsetPx({
indicatorTime: indicator.time,
@@ -581,24 +579,159 @@ function KeyframeIndicators({
interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
- isSelected: boolean;
}
-interface ElementContentRendererProps extends ElementContentProps {
- mediaAssets: MediaAsset[];
-}
-
-type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode;
-
-export function renderTiledMedia({
+function TextElementContent({
+ element,
+}: {
+ element: Extract
;
+}) {
+ return (
+
+ {element.content}
+
+ );
+}
+
+function EffectElementContent({
+ element,
+}: {
+ element: Extract;
+}) {
+ return (
+
+
+ {element.name}
+
+ );
+}
+
+function StickerElementContent({
+ element,
+}: {
+ element: Extract;
+}) {
+ return (
+
+
+ {element.name}
+
+ );
+}
+
+function GraphicElementContent({
+ element,
+}: {
+ element: Extract;
+}) {
+ return (
+
+
+ {element.name}
+
+ );
+}
+
+function AudioElementContent({ element }: { element: AudioElement }) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const mediaAsset =
+ element.sourceType === "upload"
+ ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
+ : null;
+
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+ const audioUrl =
+ element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
+ const mediaLabel = mediaAsset?.name ?? element.name;
+
+ if (audioBuffer || audioUrl) {
+ return (
+
+ );
+ }
+
+ return (
+ {element.name}
+ );
+}
+
+function EffectsButton({
element,
- imageUrl,
track,
}: {
- element: VisualElement;
- imageUrl: string | undefined;
- track: ElementContentProps["track"];
-}): ReactNode {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const editor = useEditor();
+ const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
+
+ const handleClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ editor.selection.setSelectedElements({
+ elements: [{ trackId: track.id, elementId: element.id }],
+ });
+ setActiveTab(element.type, "effects");
+ };
+
+ return (
+ event.stopPropagation()}
+ onClick={handleClick}
+ >
+
+
+ );
+}
+
+function TiledMediaContent({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ const imageUrl =
+ element.type === "video"
+ ? mediaAsset?.thumbnailUrl
+ : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
+
if (!imageUrl) {
return (
@@ -608,183 +741,80 @@ export function renderTiledMedia({
}
const trackHeight = getTrackHeight({ type: track.type });
- const tileWidth = trackHeight * (16 / 9);
+ const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
return (
-
+ <>
+
+
+ ) : null
+ }
+ hasFade={true}
+ />
+ >
);
}
-function EffectsButton({
- element,
- trackId,
- className,
+function MediaElementHeader({
+ name,
+ leading,
+ hasFade,
}: {
- element: VisualElement;
- trackId: string;
- className?: string;
+ name?: string | null;
+ leading?: ReactNode;
+ hasFade?: boolean;
}) {
- const openClipEffects = usePropertiesStore((state) => state.openClipEffects);
- const { selectElement } = useElementSelection();
-
- if (!element.effects?.length) {
+ if (!name && !leading) {
return null;
}
- const handleClick = (event: React.MouseEvent) => {
- event.stopPropagation();
- selectElement({ elementId: element.id, trackId });
- openClipEffects({ elementId: element.id, trackId });
- };
-
return (
- event.stopPropagation()}
+
-
-
+ {leading &&
{leading}
}
+ {name && (
+
+ {name}
+
+ )}
+
);
}
-const ELEMENT_CONTENT_RENDERERS: Record<
- TimelineElementType["type"],
- ElementContentRenderer
-> = {
- text: ({ element }) => {
- const textElement = element as Extract<
- TimelineElementType,
- { type: "text" }
- >;
- return (
-
-
- {textElement.content}
-
-
- );
- },
- effect: ({ element }) => (
-
-
- {element.name}
-
- ),
- sticker: ({ element }) => {
- const stickerElement = element as Extract<
- TimelineElementType,
- { type: "sticker" }
- >;
- return (
-
-
-
- {stickerElement.name}
-
-
- );
- },
- audio: ({ element, mediaAssets }) => {
- const audioElement = element as Extract<
- TimelineElementType,
- { type: "audio" }
- >;
- const audioBuffer =
- audioElement.sourceType === "library" ? audioElement.buffer : undefined;
- const audioUrl =
- audioElement.sourceType === "library"
- ? audioElement.sourceUrl
- : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url;
-
- if (audioBuffer || audioUrl) {
- return (
-
- );
- }
-
- return (
-
- {audioElement.name}
-
- );
- },
- video: ({ element, track, mediaAssets }) => {
- const videoElement = element as Extract<
- TimelineElementType,
- { type: "video" }
- >;
- const mediaAsset = mediaAssets.find(
- (asset) => asset.id === videoElement.mediaId,
- );
- return renderTiledMedia({
- element: videoElement,
- imageUrl: mediaAsset?.thumbnailUrl,
- track,
- });
- },
- image: ({ element, track, mediaAssets }) => {
- const imageElement = element as Extract<
- TimelineElementType,
- { type: "image" }
- >;
- const mediaAsset = mediaAssets.find(
- (asset) => asset.id === imageElement.mediaId,
- );
- return renderTiledMedia({
- element: imageElement,
- imageUrl: mediaAsset?.url,
- track,
- });
- },
-};
-
-function ElementContent({ element, track, isSelected }: ElementContentProps) {
- const editor = useEditor();
- const renderer = ELEMENT_CONTENT_RENDERERS[element.type];
- return (
- <>
- {renderer({
- element,
- track,
- isSelected,
- mediaAssets: editor.media.getAssets(),
- })}
- >
- );
+function ElementContent({ element, track }: ElementContentProps) {
+ switch (element.type) {
+ case "text":
+ return ;
+ case "effect":
+ return ;
+ case "sticker":
+ return ;
+ case "graphic":
+ return ;
+ case "audio":
+ return ;
+ case "video":
+ case "image":
+ return ;
+ }
}
function CopyMenuItem() {
@@ -886,7 +916,7 @@ function ActionMenuItem({
children,
...props
}: Omit, "onClick" | "textRight"> & {
- action: TAction;
+ action: TActionWithOptionalArgs;
children: ReactNode;
}) {
return (
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 e290259e..1abe65bd 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -8,9 +8,14 @@ import {
} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
+import {
+ TIMELINE_LAYERS,
+ TIMELINE_SCROLLBAR_SIZE_PX,
+} from "@/constants/timeline-constants";
interface TimelinePlayheadProps {
zoomLevel: number;
+ hasHorizontalScrollbar: boolean;
rulerRef: React.RefObject;
rulerScrollRef: React.RefObject;
tracksScrollRef: React.RefObject;
@@ -21,6 +26,7 @@ interface TimelinePlayheadProps {
export function TimelinePlayhead({
zoomLevel,
+ hasHorizontalScrollbar,
rulerRef,
rulerScrollRef,
tracksScrollRef,
@@ -33,7 +39,7 @@ export function TimelinePlayhead({
const internalPlayheadRef = useRef(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ const { handlePlayheadMouseDown } = useTimelinePlayhead({
zoomLevel,
rulerRef,
rulerScrollRef,
@@ -42,16 +48,23 @@ export function TimelinePlayhead({
});
const timelineContainerHeight =
- tracksScrollRef.current?.clientHeight ??
timelineRef.current?.clientHeight ??
+ tracksScrollRef.current?.clientHeight ??
400;
- const totalHeight = Math.max(0, timelineContainerHeight - 4);
+ const totalHeight = Math.max(
+ 0,
+ timelineContainerHeight -
+ (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0),
+ );
+ const currentTime = editor.playback.getCurrentTime();
const centerPosition = timelineTimeToSnappedPixels({
- time: playheadPosition,
+ time: currentTime,
zoomLevel,
});
- const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
+ const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
+ const leftPosition =
+ getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft;
const handlePlayheadKeyDown = (
event: React.KeyboardEvent,
@@ -61,10 +74,8 @@ export function TimelinePlayhead({
event.preventDefault();
const step = 1 / Math.max(1, editor.project.getActive().settings.fps);
const direction = event.key === "ArrowRight" ? 1 : -1;
- const nextTime = Math.max(
- 0,
- Math.min(duration, playheadPosition + direction * step),
- );
+ const now = editor.playback.getCurrentTime();
+ const nextTime = Math.max(0, Math.min(duration, now + direction * step));
editor.playback.seek({ time: nextTime });
};
@@ -76,23 +87,24 @@ export function TimelinePlayhead({
aria-label="Timeline playhead"
aria-valuemin={0}
aria-valuemax={duration}
- aria-valuenow={playheadPosition}
+ aria-valuenow={currentTime}
tabIndex={0}
- className="pointer-events-none absolute z-5"
+ className="pointer-events-none absolute"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.playhead,
}}
onKeyDown={handlePlayheadKeyDown}
>
-
+
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
index b0261aa2..09562f23 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
@@ -1,5 +1,8 @@
import { type JSX, useLayoutEffect, useRef } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ TIMELINE_CONSTANTS,
+ TIMELINE_RULER_HEIGHT,
+} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
@@ -27,13 +30,12 @@ export function TimelineRuler({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineRulerProps) {
- const editor = useEditor();
- const duration = editor.timeline.getTotalDuration();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration);
- const project = editor.project.getActive();
- const fps = project?.settings.fps ?? DEFAULT_FPS;
+ const fps =
+ useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
zoomLevel,
fps,
@@ -104,17 +106,25 @@ export function TimelineRuler({
aria-valuemin={0}
aria-valuemax={effectiveDuration}
aria-valuenow={0}
- className="relative h-4 flex-1 overflow-x-visible"
+ className="relative flex-1 overflow-x-visible"
+ style={{ height: TIMELINE_RULER_HEIGHT }}
onWheel={handleWheel}
- onClick={handleTimelineContentClick}
+ onClick={(event) => {
+ // Ruler seek already happens on mousedown via playhead scrubbing.
+ // Forwarding the follow-up click re-enters the selection-clearing path.
+ if (event.target === event.currentTarget) {
+ handleTimelineContentClick(event);
+ }
+ }}
onMouseDown={handleRulerTrackingMouseDown}
onKeyDown={() => {}}
>
{label}
@@ -32,7 +32,7 @@ export function TimelineTick({
return (
);
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
index 533ea63d..e785d147 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -16,8 +16,8 @@ import {
import { Slider } from "@/components/ui/slider";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
-import { ScenesView } from "../../scenes-view";
-import { type TAction, invokeAction } from "@/lib/actions";
+import { ScenesView } from "@/components/editor/scenes-view";
+import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -76,15 +76,15 @@ export function TimelineToolbar({
}
function ToolbarLeftSection() {
- const editor = useEditor();
- const currentTime = editor.playback.getCurrentTime();
- const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime });
+ const isCurrentlyBookmarked = useEditor((e) =>
+ e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
+ );
const handleAction = ({
action,
event,
}: {
- action: TAction;
+ action: TActionWithOptionalArgs;
event: React.MouseEvent;
}) => {
event.stopPropagation();
@@ -149,8 +149,8 @@ function ToolbarLeftSection() {
}
- isActive={isCurrentlyBookmarked}
- tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
+ isActive={isCurrentlyBookmarked}
+ tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event })
}
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 81cda7a6..6a9e4dfe 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -2,12 +2,12 @@
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TimelineElement } from "./timeline-element";
-import type { TimelineTrack } from "@/types/timeline";
-import type { TimelineElement as TimelineElementType } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
+import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import type { ElementDragState } from "@/types/timeline";
+import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
interface TimelineTrackContentProps {
@@ -30,7 +30,7 @@ interface TimelineTrackContentProps {
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
- onTrackClick?: (event: React.MouseEvent) => void;
+ onTrackMouseUp?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
targetElementId?: string | null;
}
@@ -47,14 +47,12 @@ export function TimelineTrackContent({
onElementMouseDown,
onElementClick,
onTrackMouseDown,
- onTrackClick,
+ onTrackMouseUp,
shouldIgnoreClick,
targetElementId = null,
}: TimelineTrackContentProps) {
- const editor = useEditor();
const { isElementSelected } = useElementSelection();
-
- const duration = editor.timeline.getTotalDuration();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
useEdgeAutoScroll({
isActive: dragState.isDragging,
@@ -65,21 +63,37 @@ export function TimelineTrackContent({
});
return (
- {
- if (shouldIgnoreClick?.()) return;
- onTrackClick?.(event);
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- type="button"
- >
-
+
+
{
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
+ {
+ if (event.target !== event.currentTarget) return;
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ >
{track.elements.length === 0 ? (
-
+
) : (
track.elements.map((element) => {
const isSelected = isElementSelected({
@@ -109,6 +123,6 @@ export function TimelineTrackContent({
})
)}
-
+
);
}
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index 746cfd16..35e5dfba 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -3,13 +3,12 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
+import { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
-import {
- useKeybindingsListener,
- useKeybindingDisabler,
-} from "@/hooks/use-keybindings";
+import { useKeybindingsListener } from "@/hooks/use-keybindings";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
-import { prefetchFontAtlas } from "@/lib/fonts/google-fonts";
+import { loadFontAtlas } from "@/lib/fonts/google-fonts";
interface EditorProviderProps {
projectId: string;
@@ -17,23 +16,19 @@ interface EditorProviderProps {
}
export function EditorProvider({ projectId, children }: EditorProviderProps) {
- const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActiveOrNull());
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState
(null);
- const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
- const activeProject = editor.project.getActiveOrNull();
+ const { setLoadingProject } = useKeybindingsStore();
useEffect(() => {
- if (isLoading) {
- disableKeybindings();
- } else {
- enableKeybindings();
- }
- }, [isLoading, disableKeybindings, enableKeybindings]);
+ setLoadingProject(isLoading);
+ }, [isLoading, setLoadingProject]);
useEffect(() => {
let cancelled = false;
+ const editor = EditorCore.getInstance();
const loadProject = async () => {
try {
@@ -43,7 +38,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
if (cancelled) return;
setIsLoading(false);
- prefetchFontAtlas();
+ loadFontAtlas();
} catch (err) {
if (cancelled) return;
@@ -76,7 +71,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
return () => {
cancelled = true;
};
- }, [projectId, editor, router]);
+ }, [projectId, router]);
if (error) {
return (
diff --git a/apps/web/src/components/section.tsx b/apps/web/src/components/section.tsx
new file mode 100644
index 00000000..e3fc96df
--- /dev/null
+++ b/apps/web/src/components/section.tsx
@@ -0,0 +1,257 @@
+import { createContext, useContext, useEffect, useState } from "react";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowDownIcon } from "@hugeicons/core-free-icons";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+
+const sectionExpandedCache = new Map();
+const mountedSectionKeys = new Set();
+
+interface SectionContext {
+ isOpen: boolean;
+ toggle: () => void;
+ collapsible: boolean;
+}
+
+const SectionCtx = createContext(null);
+
+function useSectionContext() {
+ return useContext(SectionCtx);
+}
+
+interface SectionProps {
+ children: React.ReactNode;
+ collapsible?: boolean;
+ defaultOpen?: boolean;
+ sectionKey?: string;
+ className?: string;
+ showTopBorder?: boolean;
+ showBottomBorder?: boolean;
+}
+
+export function Section({
+ children,
+ collapsible = false,
+ defaultOpen = true,
+ sectionKey,
+ className,
+ showTopBorder = false,
+ showBottomBorder = true,
+}: SectionProps) {
+ const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
+ const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
+
+ useEffect(() => {
+ if (!sectionKey) return;
+ if (
+ process.env.NODE_ENV !== "production" &&
+ mountedSectionKeys.has(sectionKey)
+ ) {
+ console.error(
+ `[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`,
+ );
+ }
+ mountedSectionKeys.add(sectionKey);
+ return () => {
+ mountedSectionKeys.delete(sectionKey);
+ };
+ }, [sectionKey]);
+
+ const toggle = () => {
+ const next = !isOpen;
+ setIsOpen(next);
+ if (sectionKey) sectionExpandedCache.set(sectionKey, next);
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+interface SectionHeaderProps {
+ children?: React.ReactNode;
+ trailing?: React.ReactNode;
+ leading?: React.ReactNode;
+ actions?: React.ReactNode;
+ onClick?: () => void;
+ className?: string;
+}
+
+export function SectionHeader({
+ children,
+ trailing,
+ leading,
+ actions,
+ onClick,
+ className,
+}: SectionHeaderProps) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+ const isInteractive = isCollapsible || !!onClick;
+ const handleClick = isCollapsible ? ctx?.toggle : onClick;
+
+ const chevronIcon = (
+
+ );
+
+ const trailingArea =
+ trailing || isCollapsible ? (
+
+ {trailing}
+ {isCollapsible && (
+
+ {chevronIcon}
+
+ )}
+
+ ) : null;
+
+ const innerContent = isInteractive ? (
+
+ {leading}
+ {children}
+
+ ) : (
+ <>
+ {leading}
+ {children}
+ >
+ );
+
+ return (
+
+ {innerContent}
+ {trailingArea}
+ {actions}
+
+ );
+}
+
+export function SectionTitle({
+ children,
+ className,
+ onClick,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ onClick?: () => void;
+}) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+
+ const titleClass = cn(
+ "text-sm font-medium",
+ isCollapsible && isOpen ? "text-foreground" : "text-muted-foreground",
+ className,
+ );
+
+ if (onClick) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return {children} ;
+}
+
+export function SectionFields({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ {children}
+ );
+}
+
+export function SectionField({
+ label,
+ beforeLabel,
+ children,
+ className,
+}: {
+ label: string;
+ beforeLabel?: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+ {beforeLabel}
+ {label}
+
+ {children}
+
+ );
+}
+
+export function SectionContent({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+
+ const content = {children}
;
+
+ if (isCollapsible) {
+ return (
+
+ );
+ }
+
+ return content;
+}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index aa52e228..3dff9417 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -23,7 +23,7 @@ const buttonVariants = cva(
},
size: {
default: "h-9 px-4 py-2",
- sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm",
+ sm: "h-7 p-1 px-2.5 text-sm rounded-sm",
lg: "h-10 p-5 px-6",
icon: "size-7 rounded-sm",
text: "p-0",
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
index fbf9bbd7..6d911a75 100644
--- a/apps/web/src/components/ui/color-picker.tsx
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -270,6 +270,7 @@ const ColorPicker = forwardRef(
backgroundColor: "#fff",
};
+
return (
(
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
type="button"
>
-
+
(
(
+
) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "context-menu",
+ onOpenChange,
+ });
+ return (
+
+ );
+}
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index d124a2e9..f5045f95 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -4,8 +4,22 @@ import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react";
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Dialog = DialogPrimitive.Root;
+function Dialog({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps
) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dialog",
+ open,
+ onOpenChange,
+ });
+ return (
+
+ );
+}
const DialogTrigger = DialogPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index 2d6541b8..91913e25 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -4,10 +4,27 @@ import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
-
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const DropdownMenu = DropdownMenuPrimitive.Root;
+function DropdownMenu({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dropdown-menu",
+ open,
+ onOpenChange,
+ });
+ return (
+
+ );
+}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -118,7 +135,7 @@ const DropdownMenuItem = React.forwardRef<
ref,
) => {
const iconSlot = (
-
+
{icon}
);
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index 09ba0a9a..77d8ab7f 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -1,13 +1,6 @@
"use client";
-import {
- useState,
- useMemo,
- useRef,
- useEffect,
- useCallback,
- type CSSProperties,
-} from "react";
+import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties } from "react";
import { List, type RowComponentProps } from "react-window";
import {
Popover,
@@ -16,28 +9,24 @@ import {
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
-import {
- getCachedFontAtlas,
- loadFullFont,
- prefetchFontAtlas,
- clearFontAtlasCache,
-} from "@/lib/fonts/google-fonts";
-import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
+import { loadFullFont } from "@/lib/fonts/google-fonts";
+import { SYSTEM_FONTS } from "@/constants/font-constants";
+import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types";
+import { useFontAtlas } from "@/hooks/use-font-atlas";
import { cn } from "@/utils/ui";
-import { ChevronDown, Search, Upload } from "lucide-react";
+import { ChevronDown, Search } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { TextIcon } from "@hugeicons/core-free-icons";
const FONT_TABS = [
{ key: "all", label: "All fonts" },
- { key: "favorites", label: "Favorites" },
{ key: "my-fonts", label: "My fonts" },
+ { key: "favorites", label: "Favorites" },
] as const;
type FontTab = (typeof FONT_TABS)[number]["key"];
const ROW_HEIGHT = 40;
-const SPRITE_ROW_HEIGHT = 40;
const PREVIEW_SCALE = 0.8;
const LIST_WIDTH = 288;
const MAX_LIST_HEIGHT = 288;
@@ -57,18 +46,8 @@ export function FontPicker({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState("all");
- const [atlas, setAtlas] = useState(() =>
- getCachedFontAtlas(),
- );
- const [status, setStatus] = useState<"idle" | "loading" | "error">(() =>
- getCachedFontAtlas() ? "idle" : "loading",
- );
const searchInputRef = useRef(null);
-
- const fontNames = useMemo(() => {
- if (!atlas) return [];
- return Object.keys(atlas.fonts).sort();
- }, [atlas]);
+ const { atlas, status, fontNames, retry: handleRetry } = useFontAtlas({ open });
const filteredFonts = useMemo(() => {
if (!search) return fontNames;
@@ -83,32 +62,19 @@ export function FontPicker({
const handleSelect = useCallback(
async ({ family }: { family: string }) => {
- try {
- await loadFullFont({ family });
- onValueChange?.(family);
- } catch {
- onValueChange?.(family);
+ if (!SYSTEM_FONTS.has(family)) {
+ try {
+ await loadFullFont({ family });
+ } catch {
+ // ignore load failure, font will fall back to system default
+ }
}
+ onValueChange?.(family);
setOpen(false);
},
[onValueChange],
);
- // Load atlas on first open if cache is empty (fallback when prefetch hasn't completed)
- useEffect(() => {
- if (!open || atlas) return;
-
- setStatus("loading");
- prefetchFontAtlas().then((data) => {
- if (data) {
- setAtlas(data);
- setStatus("idle");
- } else {
- setStatus("error");
- }
- });
- }, [open, atlas]);
-
useEffect(() => {
if (!open) {
setSearch("");
@@ -116,18 +82,8 @@ export function FontPicker({
}
}, [open]);
- const handleRetry = useCallback(() => {
- clearFontAtlasCache();
- setStatus("loading");
- prefetchFontAtlas().then((data) => {
- if (data) {
- setAtlas(data);
- setStatus("idle");
- } else {
- setStatus("error");
- }
- });
- }, []);
+ const activeTabLabel =
+ FONT_TABS.find((t) => t.key === activeTab)?.label.toLowerCase() ?? "";
return (
@@ -164,11 +120,11 @@ export function FontPicker({
setSearch(event.target.value)}
size="xs"
- className="w-full pl-5 bg-transparent !border-none !shadow-none"
+ className="w-full pl-5 bg-transparent border-none! shadow-none!"
/>
@@ -225,20 +181,6 @@ export function FontPicker({
style={{ height: listHeight, width: LIST_WIDTH }}
/>
)}
-
- {
- // TODO: Implement local font loading
- console.log("Load local fonts clicked");
- }}
- >
-
- Load local fonts
-
-
);
@@ -250,7 +192,7 @@ function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) {
className="shrink-0"
style={{
width: entry.w,
- height: SPRITE_ROW_HEIGHT,
+ height: ROW_HEIGHT,
backgroundColor: "currentColor",
WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
WebkitMaskPosition: `-${entry.x}px -${entry.y}px`,
@@ -283,6 +225,7 @@ function FontRow({
const fontName = filteredFonts[index];
const entry = atlas.fonts[fontName];
const isSelected = fontName === selectedFont;
+ const isSystemFont = SYSTEM_FONTS.has(fontName);
return (
-
+ {isSystemFont ? (
+
+ {fontName}
+
+ ) : (
+
+ )}
);
diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx
deleted file mode 100644
index 1c22fd2e..00000000
--- a/apps/web/src/components/ui/input-with-back.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { ArrowLeft, Search } from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-
-interface InputWithBackProps {
- isExpanded: boolean;
- setIsExpanded: (isExpanded: boolean) => void;
- placeholder?: string;
- value?: string;
- onChange?: (value: string) => void;
- disableAnimation?: boolean;
-}
-
-export function InputWithBack({
- isExpanded,
- setIsExpanded,
- placeholder = "Search anything",
- value,
- onChange,
- disableAnimation = false,
-}: InputWithBackProps) {
- const [containerRef, setContainerRef] = useState
(null);
- const [buttonOffset, setButtonOffset] = useState(-60);
-
- const smoothTransition = {
- duration: disableAnimation ? 0 : 0.35,
- ease: [0.25, 0.1, 0.25, 1] as const,
- };
-
- useEffect(() => {
- if (containerRef) {
- const rect = containerRef.getBoundingClientRect();
- setButtonOffset(-rect.left - 48);
- }
- }, [containerRef]);
-
- return (
-
-
setIsExpanded(!isExpanded)}
- >
-
-
-
-
-
-
-
- onChange?.(e.target.value)}
- />
-
-
-
- );
-}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index 762cc675..1dce2f24 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -8,18 +8,19 @@ import { forwardRef, type ComponentProps } from "react";
import { useState } from "react";
const inputVariants = cva(
- "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
- default: "selection:bg-primary selection:text-primary-foreground",
+ default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20",
+ outline: "bg-background",
destructive:
"selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
},
size: {
default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
xs: "h-7 px-3 text-xs file:h-6 file:text-xs",
- sm: "h-8 px-3 text-sm file:h-6 file:text-xs",
+ sm: "h-7 px-3 text-sm file:h-6 file:text-xs",
lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
},
},
diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx
index 48297dc0..18d71591 100644
--- a/apps/web/src/components/ui/number-field.tsx
+++ b/apps/web/src/components/ui/number-field.tsx
@@ -1,12 +1,15 @@
"use client";
import { cn } from "@/utils/ui";
-import { useRef, useState, type ComponentProps } from "react";
+import { clamp } from "@/utils/math";
+import { useRef, useState, useLayoutEffect, type ComponentProps } from "react";
import { useFocusLock } from "@/hooks/use-focus-lock";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons";
+const SUFFIX_GAP_PX = 6;
+
const DRAG_SENSITIVITIES = {
default: 1,
slow: 0.5,
@@ -14,10 +17,94 @@ const DRAG_SENSITIVITIES = {
type DragSensitivity = "default" | "slow";
+type ScrubRange = {
+ from: number;
+ to: number;
+ pixelsPerUnit: number;
+};
+
+type ScrubClamp = {
+ min?: number;
+ max?: number;
+};
+
+function clampScrubValue({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min?: number;
+ max?: number;
+}): number {
+ if (min != null && max != null) return clamp({ value, min, max });
+ if (min != null) return Math.max(min, value);
+ if (max != null) return Math.min(max, value);
+ return value;
+}
+
+function getActiveRange({
+ value,
+ direction,
+ ranges,
+}: {
+ value: number;
+ direction: number;
+ ranges: readonly ScrubRange[];
+}): ScrubRange | undefined {
+ return ranges.find((range) =>
+ direction > 0
+ ? value >= range.from && value < range.to
+ : value > range.from && value <= range.to,
+ );
+}
+
+function scrubAcrossRanges({
+ startValue,
+ pixelDelta,
+ ranges,
+ min,
+ max,
+}: {
+ startValue: number;
+ pixelDelta: number;
+ ranges: readonly ScrubRange[];
+ min?: number;
+ max?: number;
+}): number {
+ let currentValue = clampScrubValue({ value: startValue, min, max });
+ let remainingPixels = pixelDelta;
+
+ while (remainingPixels !== 0) {
+ const direction = Math.sign(remainingPixels);
+
+ const range = getActiveRange({ value: currentValue, direction, ranges });
+ if (!range) break;
+
+ const boundary = direction > 0 ? range.to : range.from;
+ const pixelsToBoundary =
+ Math.abs(boundary - currentValue) * range.pixelsPerUnit;
+
+ if (Math.abs(remainingPixels) <= pixelsToBoundary) {
+ currentValue += remainingPixels / range.pixelsPerUnit;
+ break;
+ }
+
+ currentValue = boundary;
+ remainingPixels -= direction * pixelsToBoundary;
+ }
+
+ return clampScrubValue({ value: currentValue, min, max });
+}
+
interface NumberFieldProps
extends Omit, "size" | "type"> {
icon?: React.ReactNode;
+ suffix?: string;
+ suffixClassName?: string;
dragSensitivity?: DragSensitivity;
+ scrubRanges?: readonly ScrubRange[];
+ scrubClamp?: ScrubClamp;
onScrub?: (value: number) => void;
onScrubEnd?: () => void;
allowExpressions?: boolean;
@@ -28,8 +115,12 @@ interface NumberFieldProps
function NumberField({
className,
icon,
+ suffix,
+ suffixClassName,
disabled,
dragSensitivity = "default",
+ scrubRanges,
+ scrubClamp,
onScrub,
onScrubEnd,
value,
@@ -45,9 +136,18 @@ function NumberField({
}: NumberFieldProps & { ref?: React.Ref }) {
const iconRef = useRef(null);
const inputRef = useRef(null);
+ const ghostRef = useRef(null);
const startValueRef = useRef(0);
const cumulativeDeltaRef = useRef(0);
const [isInputFocused, setIsInputFocused] = useState(false);
+ const [suffixLeft, setSuffixLeft] = useState(0);
+
+ useLayoutEffect(() => {
+ if (!suffix || !ghostRef.current || !inputRef.current) return;
+ const paddingLeft =
+ parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0;
+ setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth);
+ }, [value, suffix]);
const { containerRef: wrapperRef } = useFocusLock({
isActive: isInputFocused,
@@ -71,12 +171,16 @@ function NumberField({
return;
}
cumulativeDeltaRef.current += moveEvent.movementX;
- const sensitivity =
- typeof dragSensitivity === "number"
- ? dragSensitivity
- : DRAG_SENSITIVITIES[dragSensitivity];
- const newValue =
- startValueRef.current + cumulativeDeltaRef.current * sensitivity;
+ const newValue = scrubRanges
+ ? scrubAcrossRanges({
+ startValue: startValueRef.current,
+ pixelDelta: cumulativeDeltaRef.current,
+ ranges: scrubRanges,
+ min: scrubClamp?.min,
+ max: scrubClamp?.max,
+ })
+ : startValueRef.current +
+ cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
onScrub(newValue);
};
@@ -93,11 +197,48 @@ function NumberField({
const canScrub = Boolean(icon && onScrub);
+ const inputNode = (
+ {
+ const inputElement = event.currentTarget;
+ const shouldPreventNativeCaretPlacement =
+ event.button === 0 && document.activeElement !== inputElement;
+ if (shouldPreventNativeCaretPlacement) {
+ event.preventDefault();
+ inputElement.focus();
+ inputElement.select();
+ }
+ onMouseDown?.(event);
+ }}
+ onFocus={(event) => {
+ setIsInputFocused(true);
+ event.currentTarget.select();
+ onFocus?.(event);
+ }}
+ onKeyDown={(event) => {
+ const shouldBlurInput = event.key === "Enter" || event.key === "Escape";
+ if (shouldBlurInput) event.currentTarget.blur();
+ onKeyDown?.(event);
+ }}
+ onBlur={(event) => {
+ setIsInputFocused(false);
+ onBlur?.(event);
+ }}
+ {...props}
+ />
+ );
+
return (
event.preventDefault() : undefined}
onPointerDown={canScrub ? handleIconPointerDown : undefined}
>
{icon}
)}
-
{
- const inputElement = event.currentTarget;
- const shouldPreventNativeCaretPlacement =
- event.button === 0 && document.activeElement !== inputElement;
- if (shouldPreventNativeCaretPlacement) {
- event.preventDefault();
- inputElement.focus();
- inputElement.select();
- }
- onMouseDown?.(event);
- }}
- onFocus={(event) => {
- setIsInputFocused(true);
- event.currentTarget.select();
- onFocus?.(event);
- }}
- onKeyDown={(event) => {
- const shouldBlurInput =
- event.key === "Enter" || event.key === "Escape";
- if (shouldBlurInput) event.currentTarget.blur();
- onKeyDown?.(event);
- }}
- onBlur={(event) => {
- setIsInputFocused(false);
- onBlur?.(event);
- }}
- {...props}
- />
+ >
+ {inputNode}
+ {suffix && (
+ <>
+ {/* Ghost mirrors value text to measure width for suffix positioning */}
+
+ {value}
+
+
+ {suffix}
+
+ >
+ )}
+
{onReset && !isDefault && (
-
+
)}
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index 8fd511a3..cf480c8c 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -3,8 +3,26 @@
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Popover = PopoverPrimitive.Root;
+function Popover({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps
) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "popover",
+ open,
+ onOpenChange,
+ });
+ return (
+
+ );
+}
const PopoverTrigger = PopoverPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 1b57ea4d..b7092b87 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -7,8 +7,22 @@ import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Select = SelectPrimitive.Root;
+function Select({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "select",
+ open,
+ onOpenChange,
+ });
+ return (
+
+ );
+}
const SelectGroup = SelectPrimitive.Group;
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index 3155787d..d56cd0ac 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -4,10 +4,23 @@ import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
-
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Sheet = SheetPrimitive.Root;
+function Sheet({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "sheet",
+ open,
+ onOpenChange,
+ });
+ return (
+
+ );
+}
const SheetTrigger = SheetPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx
index 4bae2fad..f0c8197a 100644
--- a/apps/web/src/components/ui/sonner.tsx
+++ b/apps/web/src/components/ui/sonner.tsx
@@ -12,7 +12,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
("default");
+
+const Tabs = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ variant?: TabsVariant;
+ }
+>(({ variant = "default", ...props }, ref) => (
+
+
+
+));
+Tabs.displayName = TabsPrimitive.Root.displayName;
const TabsList = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+
+ );
+});
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+
+ );
+});
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+
+ );
+});
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index 067042e6..2e7b53d4 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -11,7 +11,7 @@ const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const tooltipVariants = cva(
- "z-50 overflow-visible rounded-sm text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ "z-50 overflow-visible rounded-sm text-sm shadow-md",
{
variants: {
variant: {
@@ -22,7 +22,7 @@ const tooltipVariants = cva(
important:
"bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]",
promotions:
- "bg-red-100/90 text-red-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]",
+ "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]",
personal:
"bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]",
updates:
diff --git a/apps/web/src/components/ui/use-overlay-open-change.ts b/apps/web/src/components/ui/use-overlay-open-change.ts
new file mode 100644
index 00000000..165efc79
--- /dev/null
+++ b/apps/web/src/components/ui/use-overlay-open-change.ts
@@ -0,0 +1,78 @@
+import { useCallback, useEffect, useId, useRef } from "react";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+
+export function useOverlayOpenChange({
+ source,
+ open,
+ onOpenChange,
+}: {
+ source: string;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const { openOverlay, closeOverlay } = useKeybindingsStore();
+ const isTrackedRef = useRef(false);
+ const isControlled = typeof open === "boolean";
+ const overlayId = useId();
+
+ useEffect(() => {
+ if (!isControlled) return;
+
+ if (open && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ return;
+ }
+
+ if (!open && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]);
+
+ useEffect(() => {
+ return () => {
+ if (!isTrackedRef.current) return;
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "post-fix",
+ hypothesisId: "H2",
+ location: "use-overlay-open-change.ts:cleanup",
+ message: "Overlay closed during unmount cleanup",
+ data: { source, overlayId },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ };
+ }, [closeOverlay, overlayId, source]);
+
+ return useCallback(
+ (nextOpen: boolean) => {
+ if (!isControlled) {
+ if (nextOpen && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ } else if (!nextOpen && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }
+
+ onOpenChange?.(nextOpen);
+ },
+ [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source],
+ );
+}
diff --git a/apps/web/src/constants/editor-constants.ts b/apps/web/src/constants/editor-constants.ts
index 00e8f19b..6428af32 100644
--- a/apps/web/src/constants/editor-constants.ts
+++ b/apps/web/src/constants/editor-constants.ts
@@ -1,3 +1,17 @@
+export type TPlatformLayout =
+ | "tiktok"
+ | "instagram"
+ | "youtube"
+ | "snapchat";
+
+export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200];
+
+export const PREVIEW_ZOOM = {
+ min: 0.25,
+ max: 16,
+ step: 1.25,
+};
+
export const PANEL_CONFIG = {
panels: {
tools: 25,
diff --git a/apps/web/src/constants/export-constants.ts b/apps/web/src/constants/export-constants.ts
index 4e27aae5..ffb5edc6 100644
--- a/apps/web/src/constants/export-constants.ts
+++ b/apps/web/src/constants/export-constants.ts
@@ -1,4 +1,4 @@
-import type { ExportOptions } from "@/types/export";
+import type { ExportOptions } from "@/lib/export";
export const DEFAULT_EXPORT_OPTIONS = {
format: "mp4",
diff --git a/apps/web/src/constants/font-constants.ts b/apps/web/src/constants/font-constants.ts
index d115d09b..9cc363a3 100644
--- a/apps/web/src/constants/font-constants.ts
+++ b/apps/web/src/constants/font-constants.ts
@@ -1,5 +1,3 @@
-export const DEFAULT_FONT = "Arial";
-
export const SYSTEM_FONTS = new Set([
"Arial",
"Helvetica",
diff --git a/apps/web/src/constants/guide-constants.ts b/apps/web/src/constants/guide-constants.ts
new file mode 100644
index 00000000..0b707de7
--- /dev/null
+++ b/apps/web/src/constants/guide-constants.ts
@@ -0,0 +1,4 @@
+export const GRID_MIN = 1;
+export const GRID_MAX = 24;
+
+export const DEFAULT_GRID_CONFIG = { rows: 3, cols: 3 } as const;
diff --git a/apps/web/src/constants/language-constants.ts b/apps/web/src/constants/language-constants.ts
index 3da36661..e5678145 100644
--- a/apps/web/src/constants/language-constants.ts
+++ b/apps/web/src/constants/language-constants.ts
@@ -9,3 +9,6 @@ export const LANGUAGES = [
{ code: "ja", name: "Japanese" },
{ code: "zh", name: "Chinese" },
] as const;
+
+export type Language = (typeof LANGUAGES)[number];
+export type LanguageCode = Language["code"];
diff --git a/apps/web/src/constants/mask-constants.ts b/apps/web/src/constants/mask-constants.ts
new file mode 100644
index 00000000..97e77ff0
--- /dev/null
+++ b/apps/web/src/constants/mask-constants.ts
@@ -0,0 +1,4 @@
+export const MAX_FEATHER = 1000;
+export const FEATHER_HANDLE_SCALE = 0.11;
+export const DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO = 0.6;
+export const MIN_MASK_DIMENSION = 0.01;
diff --git a/apps/web/src/constants/project-constants.ts b/apps/web/src/constants/project-constants.ts
index 7e3f06b6..24e75381 100644
--- a/apps/web/src/constants/project-constants.ts
+++ b/apps/web/src/constants/project-constants.ts
@@ -1,4 +1,4 @@
-import type { TCanvasSize } from "@/types/project";
+import type { TCanvasSize } from "@/lib/project/types";
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
{ width: 1920, height: 1080 },
@@ -16,12 +16,12 @@ export const FPS_PRESETS = [
] as const;
export const BLUR_INTENSITY_PRESETS: { label: string; value: number }[] = [
- { label: "Light", value: 4 },
- { label: "Medium", value: 8 },
- { label: "Heavy", value: 18 },
+ { label: "Light", value: 10 },
+ { label: "Medium", value: 50 },
+ { label: "Heavy", value: 100 },
] as const;
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
export const DEFAULT_FPS = 30;
-export const DEFAULT_BLUR_INTENSITY = 8;
+export const DEFAULT_BLUR_INTENSITY = 10;
export const DEFAULT_COLOR = "#000000";
diff --git a/apps/web/src/constants/retime-constants.ts b/apps/web/src/constants/retime-constants.ts
new file mode 100644
index 00000000..7b18b61c
--- /dev/null
+++ b/apps/web/src/constants/retime-constants.ts
@@ -0,0 +1,30 @@
+export const DEFAULT_RETIME_RATE = 1;
+export const MIN_RETIME_RATE = 0.01;
+export const MAX_RETIME_RATE = 5;
+export const MAX_PITCH_PRESERVE_RATE = 10;
+
+export function clampRetimeRate({ rate }: { rate: number }): number {
+ if (!Number.isFinite(rate) || rate <= 0) {
+ return DEFAULT_RETIME_RATE;
+ }
+
+ return Math.min(Math.max(rate, MIN_RETIME_RATE), MAX_RETIME_RATE);
+}
+
+export function canMaintainPitch({ rate }: { rate: number }): boolean {
+ if (!Number.isFinite(rate) || rate <= 0) {
+ return false;
+ }
+
+ return clampRetimeRate({ rate }) <= MAX_PITCH_PRESERVE_RATE;
+}
+
+export function shouldMaintainPitch({
+ rate,
+ maintainPitch,
+}: {
+ rate: number;
+ maintainPitch?: boolean;
+}): boolean {
+ return maintainPitch === true && canMaintainPitch({ rate });
+}
diff --git a/apps/web/src/constants/sticker-constants.ts b/apps/web/src/constants/sticker-constants.ts
index 506e552d..06692645 100644
--- a/apps/web/src/constants/sticker-constants.ts
+++ b/apps/web/src/constants/sticker-constants.ts
@@ -1,8 +1,8 @@
+export const STICKER_INTRINSIC_SIZE_FALLBACK = 200;
+
export const STICKER_CATEGORIES = {
all: "All",
logos: "Logos",
- icons: "Icons",
- emoji: "Emoji",
flags: "Flags",
shapes: "Shapes",
};
diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts
index 6779670b..45f76bc8 100644
--- a/apps/web/src/constants/text-constants.ts
+++ b/apps/web/src/constants/text-constants.ts
@@ -1,10 +1,3 @@
-import type { TextElement } from "@/types/timeline";
-import {
- DEFAULT_OPACITY,
- DEFAULT_TRANSFORM,
- TIMELINE_CONSTANTS,
-} from "./timeline-constants";
-
export const MIN_FONT_SIZE = 5;
export const MAX_FONT_SIZE = 300;
@@ -14,40 +7,5 @@ export const MAX_FONT_SIZE = 300;
*/
export const FONT_SIZE_SCALE_REFERENCE = 90;
-export const DEFAULT_LETTER_SPACING = 0;
-export const DEFAULT_LINE_HEIGHT = 1.2;
-
export const CORNER_RADIUS_MIN = 0;
export const CORNER_RADIUS_MAX = 100;
-
-export const DEFAULT_TEXT_BACKGROUND = {
- enabled: false,
- color: "#000000",
- cornerRadius: 0,
- paddingX: 30,
- paddingY: 42,
- offsetX: 0,
- offsetY: 0,
-};
-
-export const DEFAULT_TEXT_ELEMENT: Omit = {
- type: "text",
- name: "Text",
- content: "Default text",
- fontSize: 15,
- fontFamily: "Arial",
- color: "#ffffff",
- background: DEFAULT_TEXT_BACKGROUND,
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- letterSpacing: DEFAULT_LETTER_SPACING,
- lineHeight: DEFAULT_LINE_HEIGHT,
- duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- transform: DEFAULT_TRANSFORM,
- opacity: DEFAULT_OPACITY,
-};
diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts
new file mode 100644
index 00000000..827fab85
--- /dev/null
+++ b/apps/web/src/constants/timeline-constants.ts
@@ -0,0 +1,85 @@
+import type { ElementType, TrackType } from "@/lib/timeline";
+
+export const TRACK_CONFIG: Record<
+ TrackType,
+ {
+ height: number;
+ defaultName: string;
+ }
+> = {
+ video: {
+ height: 65,
+ defaultName: "Video track",
+ },
+ text: {
+ height: 25,
+ defaultName: "Text track",
+ },
+ audio: {
+ height: 50,
+ defaultName: "Audio track",
+ },
+ graphic: {
+ height: 25,
+ defaultName: "Graphic track",
+ },
+ effect: {
+ height: 25,
+ defaultName: "Effect track",
+ },
+} as const;
+
+export const ELEMENT_TYPE_CONFIG: Record<
+ TrackType,
+ {
+ background: string;
+ waveformColor?: string;
+ }
+> = {
+ video: { background: "transparent" },
+ text: { background: "bg-[#5DBAA0]" },
+ audio: {
+ background: "bg-[#8F5DBA]",
+ waveformColor: "rgba(255, 255, 255, 0.5)",
+ },
+ graphic: { background: "bg-[#BA5D7A]" },
+ effect: { background: "bg-[#5d93ba]" },
+} as const;
+
+export const ELEMENT_TRACK_MAP: Record = {
+ audio: "audio",
+ text: "text",
+ sticker: "graphic",
+ graphic: "graphic",
+ effect: "effect",
+ video: "video",
+ image: "video",
+};
+
+export const TRACK_GAP = 6;
+export const TRACK_LABELS_WIDTH_PX = 112;
+
+export const TIMELINE_RULER_HEIGHT = 22;
+export const TIMELINE_BOOKMARK_ROW_HEIGHT = 16;
+
+export const DEFAULT_BOOKMARK_COLOR = "#009dff";
+export const DRAG_THRESHOLD_PX = 5;
+export const TIMELINE_SCROLLBAR_SIZE_PX = 12;
+export const TIMELINE_LAYERS = {
+ trackContent: 10,
+ dragLine: 20,
+ playhead: 30,
+ snapIndicator: 40,
+} as const;
+
+export const TIMELINE_CONSTANTS = {
+ PIXELS_PER_SECOND: 50,
+ DEFAULT_ELEMENT_DURATION: 5,
+ PADDING_TOP_PX: 2,
+ HORIZONTAL_WHEEL_STEP_PX: 40,
+ ZOOM_MIN: 0.1,
+ ZOOM_MAX: 100,
+ ZOOM_BUTTON_FACTOR: 1.7,
+ ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
+ TRACK_SELECTED_BG: "bg-accent/50",
+} as const;
diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx
deleted file mode 100644
index 5bc938bb..00000000
--- a/apps/web/src/constants/timeline-constants.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import type { TTimelineViewState } from "@/types/project";
-import type { BlendMode } from "@/types/rendering";
-import type { TrackType, Transform } from "@/types/timeline";
-import {
- Happy01Icon,
- MagicWand05Icon,
- MusicNote03Icon,
- TextIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { OcVideoIcon } from "@opencut/ui/icons";
-
-export const DEFAULT_TRANSFORM: Transform = {
- scale: 1,
- position: { x: 0, y: 0 },
- rotate: 0,
-};
-
-export const DEFAULT_OPACITY = 1;
-export const DEFAULT_BLEND_MODE: BlendMode = "normal";
-export const DEFAULT_BOOKMARK_COLOR = "#009dff";
-
-export const TRACK_CONFIG: Record<
- TrackType,
- {
- background: string;
- height: number;
- defaultName: string;
- icon: React.ReactNode;
- }
-> = {
- video: {
- background: "transparent",
- height: 60,
- defaultName: "Video track",
- icon: ,
- },
- text: {
- background: "bg-[#5DBAA0]",
- height: 25,
- defaultName: "Text track",
- icon: (
-
- ),
- },
- audio: {
- background: "bg-[#8F5DBA]",
- height: 50,
- defaultName: "Audio track",
- icon: (
-
- ),
- },
- sticker: {
- background: "bg-[#BA5D7A]",
- height: 50,
- defaultName: "Sticker track",
- icon: (
-
- ),
- },
- effect: {
- background: "bg-[#5d93ba]",
- height: 25,
- defaultName: "Effect track",
- icon: (
-
- ),
- },
-} as const;
-
-export const TRACK_GAP = 4;
-
-export const DRAG_THRESHOLD_PX = 5;
-
-export const TIMELINE_CONSTANTS = {
- PIXELS_PER_SECOND: 50,
- DEFAULT_ELEMENT_DURATION: 5,
- PADDING_TOP_PX: 0,
- ZOOM_MIN: 0.1,
- ZOOM_MAX: 100,
- ZOOM_BUTTON_FACTOR: 1.7,
- ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
-} as const;
-
-export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
- zoomLevel: 1,
- scrollLeft: 0,
- playheadTime: 0,
-};
diff --git a/apps/web/src/constants/transcription-constants.ts b/apps/web/src/constants/transcription-constants.ts
index 3fa914f2..c511109f 100644
--- a/apps/web/src/constants/transcription-constants.ts
+++ b/apps/web/src/constants/transcription-constants.ts
@@ -2,8 +2,8 @@ import { LANGUAGES } from "@/constants/language-constants";
import type {
TranscriptionModel,
TranscriptionModelId,
-} from "@/types/transcription";
-import type { LanguageCode } from "@/types/language";
+} from "@/lib/transcription/types";
+import type { LanguageCode } from "@/constants/language-constants";
const SUPPORTED_TRANSCRIPTION_LANGS: ReadonlyArray = [
"en",
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts
index 42ab3b87..e7cc5496 100644
--- a/apps/web/src/core/index.ts
+++ b/apps/web/src/core/index.ts
@@ -9,13 +9,13 @@ import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects";
+import { registerDefaultMasks } from "@/lib/masks";
export class EditorCore {
private static instance: EditorCore | null = null;
-
+ public readonly timeline: TimelineManager;
public readonly command: CommandManager;
public readonly playback: PlaybackManager;
- public readonly timeline: TimelineManager;
public readonly scenes: ScenesManager;
public readonly project: ProjectManager;
public readonly media: MediaManager;
@@ -26,9 +26,10 @@ export class EditorCore {
private constructor() {
registerDefaultEffects();
+ registerDefaultMasks();
this.command = new CommandManager();
- this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
+ this.playback = new PlaybackManager(this);
this.scenes = new ScenesManager(this);
this.project = new ProjectManager(this);
this.media = new MediaManager(this);
diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts
index 20b124b6..54fda6c2 100644
--- a/apps/web/src/core/managers/audio-manager.ts
+++ b/apps/web/src/core/managers/audio-manager.ts
@@ -1,6 +1,20 @@
import type { EditorCore } from "@/core";
+import {
+ clampRetimeRate,
+ shouldMaintainPitch,
+} from "@/constants/retime-constants";
import type { AudioClipSource } from "@/lib/media/audio";
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
+import {
+ buildAudioGainAutomation,
+ hasAnimatedVolume,
+} from "@/lib/timeline/audio-state";
+import { createAudioMasteringChain } from "@/lib/media/audio-mastering";
+import {
+ getClipTimeAtSourceTime,
+ getSourceTimeAtClipTime,
+ renderRetimedBuffer,
+} from "@/lib/retime";
import {
ALL_FORMATS,
AudioBufferSink,
@@ -26,6 +40,8 @@ export class AudioManager {
AsyncGenerator
>();
private queuedSources = new Set();
+ private preparedClipBuffers = new Map>();
+ private decodedBuffers = new Map>();
private playbackSessionId = 0;
private lastIsPlaying = false;
private lastVolume = 1;
@@ -55,6 +71,8 @@ export class AudioManager {
window.removeEventListener("playback-seek", this.handleSeek);
}
this.disposeSinks();
+ this.preparedClipBuffers.clear();
+ this.decodedBuffers.clear();
if (this.audioContext) {
void this.audioContext.close();
this.audioContext = null;
@@ -102,6 +120,8 @@ export class AudioManager {
private handleTimelineChange = (): void => {
this.disposeSinks();
+ this.preparedClipBuffers.clear();
+ this.decodedBuffers.clear();
if (!this.editor.playback.getIsPlaying()) return;
@@ -113,9 +133,12 @@ export class AudioManager {
if (typeof window === "undefined") return null;
this.audioContext = createAudioContext();
- this.masterGain = this.audioContext.createGain();
+ const { input } = createAudioMasteringChain({
+ audioContext: this.audioContext,
+ destination: this.audioContext.destination,
+ });
+ this.masterGain = input;
this.masterGain.gain.value = this.lastVolume;
- this.masterGain.connect(this.audioContext.destination);
return this.audioContext;
}
@@ -179,11 +202,19 @@ export class AudioManager {
if (clip.startTime > windowEnd) continue;
this.activeClipIds.add(clip.id);
- void this.runClipIterator({
- clip,
- startTime: currentTime,
- sessionId: this.playbackSessionId,
- });
+ if (this.shouldUsePreparedClipBuffer({ clip })) {
+ void this.schedulePreparedClip({
+ clip,
+ startTime: currentTime,
+ sessionId: this.playbackSessionId,
+ });
+ } else {
+ void this.runClipIterator({
+ clip,
+ startTime: currentTime,
+ sessionId: this.playbackSessionId,
+ });
+ }
}
}
@@ -236,7 +267,11 @@ export class AudioManager {
return;
}
const sourceStartTime =
- clip.trimStart + (iteratorStartTime - clip.startTime);
+ clip.trimStart +
+ getSourceTimeAtClipTime({
+ clipTime: iteratorStartTime - clip.startTime,
+ retime: clip.retime,
+ });
const iterator = sink.buffers(sourceStartTime);
this.clipIterators.set(clip.id, iterator);
@@ -246,12 +281,23 @@ export class AudioManager {
if (!this.editor.playback.getIsPlaying()) return;
if (sessionId !== this.playbackSessionId) return;
- const timelineTime = clip.startTime + (timestamp - clip.trimStart);
+ const timelineTime =
+ clip.startTime +
+ getClipTimeAtSourceTime({
+ sourceTime: timestamp - clip.trimStart,
+ retime: clip.retime,
+ });
if (timelineTime >= clipEnd) break;
const node = audioContext.createBufferSource();
node.buffer = buffer;
- node.connect(this.masterGain ?? audioContext.destination);
+ if (clip.retime) {
+ node.playbackRate.value = clampRetimeRate({ rate: clip.retime.rate });
+ }
+ const clipGain = audioContext.createGain();
+ clipGain.gain.value = clip.volume;
+ node.connect(clipGain);
+ clipGain.connect(this.masterGain ?? audioContext.destination);
const startTimestamp =
this.playbackStartContextTime +
@@ -277,8 +323,7 @@ export class AudioManager {
nextCompensationSeconds >
this.playbackLatencyCompensationSeconds + 0.001
) {
- this.playbackLatencyCompensationSeconds =
- nextCompensationSeconds;
+ this.playbackLatencyCompensationSeconds = nextCompensationSeconds;
}
const resyncStartTime = this.getPlaybackTime();
this.clipIterators.delete(clip.id);
@@ -296,6 +341,7 @@ export class AudioManager {
this.queuedSources.add(node);
node.addEventListener("ended", () => {
node.disconnect();
+ clipGain.disconnect();
this.queuedSources.delete(node);
});
@@ -311,6 +357,73 @@ export class AudioManager {
// the set is cleared on stopPlayback anyway
}
+ private async schedulePreparedClip({
+ clip,
+ startTime,
+ sessionId,
+ }: {
+ clip: AudioClipSource;
+ startTime: number;
+ sessionId: number;
+ }): Promise {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) return;
+
+ const buffer = await this.getPreparedClipBuffer({ clip });
+ if (!buffer || !this.editor.playback.getIsPlaying()) return;
+ if (sessionId !== this.playbackSessionId) return;
+
+ const clipStart = clip.startTime;
+ const clipEnd = clip.startTime + clip.duration;
+ const playbackTimeAfterReady = this.getPlaybackTime();
+ const effectiveStartTime = Math.max(
+ startTime,
+ clipStart,
+ playbackTimeAfterReady,
+ );
+ if (effectiveStartTime >= clipEnd) {
+ return;
+ }
+
+ const node = audioContext.createBufferSource();
+ node.buffer = buffer;
+ const clipGain = audioContext.createGain();
+ node.connect(clipGain);
+ clipGain.connect(this.masterGain ?? audioContext.destination);
+
+ const startTimestamp =
+ this.playbackStartContextTime +
+ this.playbackLatencyCompensationSeconds +
+ (effectiveStartTime - this.playbackStartTime);
+ const clipOffset = effectiveStartTime - clipStart;
+ let actualStartTimestamp = startTimestamp;
+ let actualClipOffset = clipOffset;
+
+ if (startTimestamp >= audioContext.currentTime) {
+ node.start(startTimestamp, clipOffset);
+ } else {
+ const lateOffset = audioContext.currentTime - startTimestamp;
+ actualStartTimestamp = audioContext.currentTime;
+ actualClipOffset = clipOffset + lateOffset;
+ node.start(actualStartTimestamp, actualClipOffset);
+ }
+
+ this.scheduleClipGainAutomation({
+ audioContext,
+ clip,
+ clipGain,
+ startTimestamp: actualStartTimestamp,
+ startLocalTime: actualClipOffset,
+ });
+
+ this.queuedSources.add(node);
+ node.addEventListener("ended", () => {
+ node.disconnect();
+ clipGain.disconnect();
+ this.queuedSources.delete(node);
+ });
+ }
+
private waitUntilCaughtUp({
timelineTime,
targetAhead,
@@ -349,6 +462,221 @@ export class AudioManager {
this.sinks.clear();
}
+ private shouldUsePreparedClipBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): boolean {
+ return (
+ this.hasCurveRetime({ clip }) ||
+ hasAnimatedVolume({ element: clip.timelineElement }) ||
+ shouldMaintainPitch({
+ rate: clip.retime?.rate ?? 1,
+ maintainPitch: clip.retime?.maintainPitch,
+ })
+ );
+ }
+
+ private hasCurveRetime({ clip }: { clip: AudioClipSource }): boolean {
+ const mode = (clip.retime as { mode?: unknown } | undefined)?.mode;
+ return mode === "curve";
+ }
+
+ private scheduleClipGainAutomation({
+ audioContext,
+ clip,
+ clipGain,
+ startTimestamp,
+ startLocalTime,
+ }: {
+ audioContext: AudioContext;
+ clip: AudioClipSource;
+ clipGain: GainNode;
+ startTimestamp: number;
+ startLocalTime: number;
+ }): void {
+ clipGain.gain.cancelScheduledValues(startTimestamp);
+ clipGain.gain.setValueAtTime(clip.volume, startTimestamp);
+
+ if (!hasAnimatedVolume({ element: clip.timelineElement })) {
+ return;
+ }
+
+ const points = buildAudioGainAutomation({
+ element: clip.timelineElement,
+ fromLocalTime: startLocalTime,
+ toLocalTime: clip.duration,
+ });
+
+ if (points.length === 0) {
+ return;
+ }
+
+ clipGain.gain.setValueAtTime(points[0].gain, startTimestamp);
+ for (let index = 1; index < points.length; index++) {
+ const point = points[index];
+ const pointTimestamp =
+ startTimestamp + (point.localTime - startLocalTime);
+ if (pointTimestamp < audioContext.currentTime) {
+ continue;
+ }
+
+ clipGain.gain.linearRampToValueAtTime(point.gain, pointTimestamp);
+ }
+ }
+
+ private buildPreparedClipCacheKey({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): string {
+ return JSON.stringify({
+ id: clip.id,
+ sourceKey: clip.sourceKey,
+ startTime: clip.startTime,
+ duration: clip.duration,
+ trimStart: clip.trimStart,
+ trimEnd: clip.trimEnd,
+ retime: clip.retime ?? null,
+ });
+ }
+
+ private async getPreparedClipBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): Promise {
+ const cacheKey = this.buildPreparedClipCacheKey({ clip });
+ const existing = this.preparedClipBuffers.get(cacheKey);
+ if (existing) {
+ return existing;
+ }
+
+ const promise = (async () => {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) {
+ return null;
+ }
+
+ const decodedBuffer = await this.getDecodedBuffer({ clip });
+ if (!decodedBuffer) {
+ return null;
+ }
+
+ return await renderRetimedBuffer({
+ audioContext,
+ sourceBuffer: decodedBuffer,
+ trimStart: clip.trimStart,
+ clipDuration: clip.duration,
+ retime: clip.retime,
+ maintainPitch: clip.retime?.maintainPitch === true,
+ });
+ })();
+
+ this.preparedClipBuffers.set(cacheKey, promise);
+ return promise;
+ }
+
+ private async getDecodedBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): Promise {
+ const existing = this.decodedBuffers.get(clip.sourceKey);
+ if (existing) {
+ return existing;
+ }
+
+ const promise = this.decodeClipBuffer({ clip });
+ this.decodedBuffers.set(clip.sourceKey, promise);
+ return promise;
+ }
+
+ private async decodeClipBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): Promise {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) {
+ return null;
+ }
+
+ const input = new Input({
+ source: new BlobSource(clip.file),
+ formats: ALL_FORMATS,
+ });
+
+ try {
+ const audioTrack = await input.getPrimaryAudioTrack();
+ if (!audioTrack) {
+ return null;
+ }
+
+ const sink = new AudioBufferSink(audioTrack);
+ const chunks: AudioBuffer[] = [];
+ let totalSamples = 0;
+
+ for await (const { buffer } of sink.buffers(0)) {
+ chunks.push(buffer);
+ totalSamples += buffer.length;
+ }
+
+ if (chunks.length === 0) {
+ return null;
+ }
+
+ const targetSampleRate = audioContext.sampleRate;
+ const nativeSampleRate = chunks[0].sampleRate;
+ const numChannels = Math.min(2, chunks[0].numberOfChannels);
+ const nativeChannels = Array.from(
+ { length: numChannels },
+ () => new Float32Array(totalSamples),
+ );
+
+ let offset = 0;
+ for (const chunk of chunks) {
+ for (let channel = 0; channel < numChannels; channel++) {
+ nativeChannels[channel].set(
+ chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1)),
+ offset,
+ );
+ }
+ offset += chunk.length;
+ }
+
+ const outputSamples = Math.ceil(
+ totalSamples * (targetSampleRate / nativeSampleRate),
+ );
+ const offlineContext = new OfflineAudioContext(
+ numChannels,
+ outputSamples,
+ targetSampleRate,
+ );
+ const nativeBuffer = audioContext.createBuffer(
+ numChannels,
+ totalSamples,
+ nativeSampleRate,
+ );
+
+ for (let channel = 0; channel < numChannels; channel++) {
+ nativeBuffer.copyToChannel(nativeChannels[channel], channel);
+ }
+
+ const sourceNode = offlineContext.createBufferSource();
+ sourceNode.buffer = nativeBuffer;
+ sourceNode.connect(offlineContext.destination);
+ sourceNode.start(0);
+
+ return await offlineContext.startRendering();
+ } catch (error) {
+ console.warn("Failed to decode clip audio:", error);
+ return null;
+ } finally {
+ input.dispose();
+ }
+ }
+
private async getAudioSink({
clip,
}: {
diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts
index fd8726d9..cdab13ef 100644
--- a/apps/web/src/core/managers/media-manager.ts
+++ b/apps/web/src/core/managers/media-manager.ts
@@ -1,9 +1,10 @@
import type { EditorCore } from "@/core";
-import type { MediaAsset } from "@/types/assets";
+import { toast } from "sonner";
+import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { generateUUID } from "@/utils/id";
import { videoCache } from "@/services/video-cache/service";
-import { hasMediaId } from "@/lib/timeline/element-utils";
+import { BatchCommand, RemoveMediaAssetCommand } from "@/lib/commands";
export class MediaManager {
private assets: MediaAsset[] = [];
@@ -18,7 +19,7 @@ export class MediaManager {
}: {
projectId: string;
asset: Omit;
- }): Promise {
+ }): Promise {
const newAsset: MediaAsset = {
...asset,
id: generateUUID(),
@@ -29,54 +30,46 @@ export class MediaManager {
try {
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
+ return newAsset;
} catch (error) {
console.error("Failed to save media asset:", error);
this.assets = this.assets.filter((asset) => asset.id !== newAsset.id);
this.notify();
+
+ if (storageService.isQuotaExceededError({ error })) {
+ toast.error("Not enough browser storage", {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+
+ return null;
}
}
- async removeMediaAsset({
+ removeMediaAsset({ projectId, id }: { projectId: string; id: string }): void {
+ this.removeMediaAssets({ projectId, ids: [id] });
+ }
+
+ removeMediaAssets({
projectId,
- id,
+ ids,
}: {
projectId: string;
- id: string;
- }): Promise {
- const asset = this.assets.find((asset) => asset.id === id);
-
- videoCache.clearVideo({ mediaId: id });
-
- if (asset?.url) {
- URL.revokeObjectURL(asset.url);
- if (asset.thumbnailUrl) {
- URL.revokeObjectURL(asset.thumbnailUrl);
- }
+ ids: string[];
+ }): void {
+ const uniqueIds = [...new Set(ids)];
+ if (uniqueIds.length === 0) {
+ return;
}
- this.assets = this.assets.filter((asset) => asset.id !== id);
- this.notify();
+ const command =
+ uniqueIds.length === 1
+ ? new RemoveMediaAssetCommand(projectId, uniqueIds[0])
+ : new BatchCommand(
+ uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)),
+ );
- const tracks = this.editor.timeline.getTracks();
- const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
-
- for (const track of tracks) {
- for (const element of track.elements) {
- if (hasMediaId(element) && element.mediaId === id) {
- elementsToRemove.push({ trackId: track.id, elementId: element.id });
- }
- }
- }
-
- if (elementsToRemove.length > 0) {
- this.editor.timeline.deleteElements({ elements: elementsToRemove });
- }
-
- try {
- await storageService.deleteMediaAsset({ projectId, id });
- } catch (error) {
- console.error("Failed to delete media asset:", error);
- }
+ this.editor.command.execute({ command });
}
async loadProjectMedia({ projectId }: { projectId: string }): Promise {
@@ -157,6 +150,8 @@ export class MediaManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts
index dacd76c0..5993a503 100644
--- a/apps/web/src/core/managers/playback-manager.ts
+++ b/apps/web/src/core/managers/playback-manager.ts
@@ -11,7 +11,15 @@ export class PlaybackManager {
private playbackTimer: number | null = null;
private lastUpdate = 0;
- constructor(private editor: EditorCore) {}
+ constructor(private editor: EditorCore) {
+ this.editor.timeline.subscribe(() => {
+ const duration = this.editor.timeline.getTotalDuration();
+ if (this.currentTime > duration && duration > 0) {
+ this.currentTime = duration;
+ this.notify();
+ }
+ });
+ }
play(): void {
const duration = this.editor.timeline.getTotalDuration();
@@ -117,7 +125,9 @@ export class PlaybackManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
private startTimer(): void {
@@ -158,7 +168,6 @@ export class PlaybackManager {
);
} else {
this.currentTime = newTime;
- this.notify();
window.dispatchEvent(
new CustomEvent("playback-update", {
diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts
index 05889a94..f84a25e3 100644
--- a/apps/web/src/core/managers/project-manager.ts
+++ b/apps/web/src/core/managers/project-manager.ts
@@ -6,8 +6,8 @@ import type {
TProjectSortOption,
TProjectSettings,
TTimelineViewState,
-} from "@/types/project";
-import type { ExportOptions, ExportResult, ExportState } from "@/types/export";
+} from "@/lib/project/types";
+import type { ExportOptions, ExportResult, ExportState } from "@/lib/export";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
@@ -26,9 +26,9 @@ import {
runStorageMigrations,
type MigrationProgress,
} from "@/services/storage/migrations";
-import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
import { loadFonts } from "@/lib/fonts/google-fonts";
-import { collectFontFamilies } from "@/lib/timeline/element-utils";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { getElementFontFamilies } from "@/lib/timeline/element-utils";
export interface MigrationState {
isMigrating: boolean;
@@ -94,6 +94,8 @@ export class ProjectManager {
settings: {
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
+ canvasSizeMode: "preset",
+ lastCustomCanvasSize: null,
originalCanvasSize: null,
background: {
type: "color",
@@ -155,7 +157,9 @@ export class ProjectManager {
await this.editor.media.loadProjectMedia({ projectId: id });
const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
- await loadFonts({ families: collectFontFamilies({ tracks: allTracks }) });
+ await loadFonts({
+ families: getElementFontFamilies({ tracks: allTracks }),
+ });
if (!project.metadata.thumbnail) {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
@@ -239,14 +243,21 @@ export class ProjectManager {
this.notify();
}
- await this.ensureStorageMigrations();
try {
- const metadata = await storageService.loadAllProjectsMetadata();
- this.savedProjects = metadata;
- this.notify();
+ await this.ensureStorageMigrations();
+ try {
+ const metadata = await storageService.loadAllProjectsMetadata();
+ this.savedProjects = metadata;
+ this.notify();
+ } catch (error) {
+ console.error("Failed to load projects:", error);
+ } finally {
+ this.isLoading = false;
+ this.isInitialized = true;
+ this.notify();
+ }
} catch (error) {
- console.error("Failed to load projects:", error);
- } finally {
+ console.error("Failed to run migrations:", error);
this.isLoading = false;
this.isInitialized = true;
this.notify();
@@ -495,10 +506,12 @@ export class ProjectManager {
}
async prepareExit(): Promise {
+ console.log("prepareExit", this.active);
if (!this.active) return;
try {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
+ console.log("didUpdateThumbnail", didUpdateThumbnail);
if (didUpdateThumbnail) {
await this.editor.save.flush();
}
@@ -571,7 +584,7 @@ export class ProjectManager {
}
getTimelineViewState(): TTimelineViewState {
- return this.active?.timelineViewState ?? DEFAULT_TIMELINE_VIEW_STATE;
+ return this.active?.timelineViewState ?? DEFAULTS.timeline.viewState;
}
setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void {
@@ -581,6 +594,7 @@ export class ProjectManager {
timelineViewState: viewState ?? undefined,
};
this.editor.save.markDirty();
+ this.notify();
}
getSavedProjects(): TProjectMetadata[] {
@@ -615,15 +629,12 @@ export class ProjectManager {
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration();
-
- if (duration === 0) return false;
-
const { canvasSize, background } = this.active.settings;
const scene = buildScene({
tracks,
mediaAssets,
- duration,
+ duration: duration || 1,
canvasSize,
background,
});
@@ -656,7 +667,7 @@ export class ProjectManager {
);
if (index !== -1) {
- this.savedProjects[index] = project.metadata;
+ this.savedProjects = this.savedProjects.with(index, project.metadata);
} else {
this.savedProjects = [project.metadata, ...this.savedProjects];
}
@@ -665,6 +676,8 @@ export class ProjectManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts
index 601a007e..75184c53 100644
--- a/apps/web/src/core/managers/renderer-manager.ts
+++ b/apps/web/src/core/managers/renderer-manager.ts
@@ -1,6 +1,6 @@
import type { EditorCore } from "@/core";
import type { RootNode } from "@/services/renderer/nodes/root-node";
-import type { ExportOptions, ExportResult } from "@/types/export";
+import type { ExportOptions, ExportResult } from "@/lib/export";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
@@ -8,6 +8,10 @@ import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
import { downloadBlob } from "@/utils/browser";
+type SnapshotResult =
+ | { success: true; blob: Blob; filename: string }
+ | { success: false; error: string };
+
export class RendererManager {
private renderTree: RootNode | null = null;
private listeners = new Set<() => void>();
@@ -24,6 +28,45 @@ export class RendererManager {
}
async saveSnapshot(): Promise<{ success: boolean; error?: string }> {
+ const snapshot = await this.createSnapshot();
+ if (!snapshot.success) {
+ return snapshot;
+ }
+
+ downloadBlob({ blob: snapshot.blob, filename: snapshot.filename });
+ return { success: true };
+ }
+
+ async copySnapshot(): Promise<{ success: boolean; error?: string }> {
+ if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
+ return {
+ success: false,
+ error: "Clipboard image copy is not supported in this browser",
+ };
+ }
+
+ const snapshot = await this.createSnapshot();
+ if (!snapshot.success) {
+ return snapshot;
+ }
+
+ try {
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ [snapshot.blob.type || "image/png"]: snapshot.blob,
+ }),
+ ]);
+ return { success: true };
+ } catch (error) {
+ console.error("Copy snapshot failed:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
+ }
+ }
+
+ private async createSnapshot(): Promise {
try {
const renderTree = this.getRenderTree();
const activeProject = this.editor.project.getActive();
@@ -70,15 +113,14 @@ export class RendererManager {
timeInSeconds: renderTime,
fps,
}).replace(/:/g, "-");
- const safeName = activeProject.metadata.name
- .replace(/[<>:"/\\|?*]/g, "-")
- .trim() || "snapshot";
+ const safeName =
+ activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
+ "snapshot";
const filename = `${safeName}-${timecode}.png`;
- downloadBlob({ blob, filename });
- return { success: true };
+ return { success: true, blob, filename };
} catch (error) {
- console.error("Save snapshot failed:", error);
+ console.error("Snapshot capture failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
@@ -193,6 +235,8 @@ export class RendererManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts
index c844f448..b42a9b7c 100644
--- a/apps/web/src/core/managers/scenes-manager.ts
+++ b/apps/web/src/core/managers/scenes-manager.ts
@@ -1,5 +1,5 @@
import type { EditorCore } from "@/core";
-import type { TimelineTrack, TScene } from "@/types/timeline";
+import type { TimelineTrack, TScene } from "@/lib/timeline";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
@@ -302,7 +302,9 @@ export class ScenesManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts
index e4295c11..2062cfb0 100644
--- a/apps/web/src/core/managers/selection-manager.ts
+++ b/apps/web/src/core/managers/selection-manager.ts
@@ -1,7 +1,6 @@
import type { EditorCore } from "@/core";
-import type { SelectedKeyframeRef } from "@/types/animation";
-
-type ElementRef = { trackId: string; elementId: string };
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
+import type { ElementRef } from "@/lib/timeline/types";
export class SelectionManager {
private selectedElements: ElementRef[] = [];
@@ -67,6 +66,8 @@ export class SelectionManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 173f0744..455222d6 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -1,16 +1,17 @@
import type { EditorCore } from "@/core";
-import type { EffectParamValues } from "@/types/effects";
+import type { ParamValues } from "@/lib/params";
import type {
TrackType,
TimelineTrack,
TimelineElement,
ClipboardItem,
-} from "@/types/timeline";
+ RetimeConfig,
+} from "@/lib/timeline";
import type {
+ AnimationPath,
AnimationInterpolation,
- AnimationPropertyPath,
AnimationValue,
-} from "@/types/animation";
+} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import {
AddTrackCommand,
@@ -30,6 +31,7 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
+ UpdateElementRetimeCommand,
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
@@ -38,15 +40,18 @@ import {
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
ReorderClipEffectsCommand,
+ RemoveMaskCommand,
+ ToggleMaskInvertedCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
} from "@/lib/commands/timeline";
-import { BatchCommand, PreviewTracker } from "@/lib/commands";
+import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
- private previewTracker = new PreviewTracker();
+ private previewOverlay = new Map>();
+ private previewTracks: TimelineTrack[] | null = null;
constructor(private editor: EditorCore) {}
@@ -121,6 +126,29 @@ export class TimelineManager {
}
}
+ updateElementRetime({
+ trackId,
+ elementId,
+ retime,
+ pushHistory = true,
+ }: {
+ trackId: string;
+ elementId: string;
+ retime?: RetimeConfig;
+ pushHistory?: boolean;
+ }): void {
+ const command = new UpdateElementRetimeCommand({
+ trackId,
+ elementId,
+ retime,
+ });
+ if (pushHistory) {
+ this.editor.command.execute({ command });
+ } else {
+ command.execute();
+ }
+ }
+
updateElementStartTime({
elements,
startTime,
@@ -308,6 +336,23 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
+ removeMask({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }): void {
+ const command = new RemoveMaskCommand({
+ trackId,
+ elementId,
+ maskId,
+ });
+ this.editor.command.execute({ command });
+ }
+
updateClipEffectParams({
trackId,
elementId,
@@ -318,7 +363,7 @@ export class TimelineManager {
trackId: string;
elementId: string;
effectId: string;
- params: Partial;
+ params: Partial;
pushHistory?: boolean;
}): void {
const command = new UpdateClipEffectParamsCommand({
@@ -351,6 +396,23 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
+ toggleMaskInverted({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }): void {
+ const command = new ToggleMaskInvertedCommand({
+ trackId,
+ elementId,
+ maskId,
+ });
+ this.editor.command.execute({ command });
+ }
+
reorderClipEffects({
trackId,
elementId,
@@ -377,7 +439,7 @@ export class TimelineManager {
keyframes: Array<{
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
@@ -419,7 +481,7 @@ export class TimelineManager {
keyframes: Array<{
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}>;
}): void {
@@ -450,7 +512,7 @@ export class TimelineManager {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
time: number;
}): void {
@@ -520,7 +582,7 @@ export class TimelineManager {
}
isPreviewActive(): boolean {
- return this.previewTracker.isActive();
+ return this.previewOverlay.size > 0;
}
previewElements({
@@ -532,37 +594,51 @@ export class TimelineManager {
updates: Partial;
}>;
}): void {
- const tracks = this.getTracks();
- this.previewTracker.begin({ state: tracks });
-
- let updatedTracks = tracks;
- for (const { trackId, elementId, updates: elementUpdates } of updates) {
- updatedTracks = updatedTracks.map((track) => {
- if (track.id !== trackId) return track;
- const newElements = track.elements.map((element) =>
- element.id === elementId
- ? { ...element, ...elementUpdates }
- : element,
- );
- return { ...track, elements: newElements } as TimelineTrack;
- });
+ for (const { elementId, updates: elementUpdates } of updates) {
+ const existingOverlay = this.previewOverlay.get(elementId);
+ const mergedOverlay = {
+ ...existingOverlay,
+ ...elementUpdates,
+ } as Partial;
+ this.previewOverlay.set(elementId, mergedOverlay);
}
- this.updateTracks(updatedTracks);
+ const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
+ this.previewTracks = this.applyPreviewOverlay(committedTracks);
+ this.notify();
}
commitPreview(): void {
- const snapshot = this.previewTracker.end();
- if (snapshot === null) return;
- const currentTracks = this.getTracks();
- const command = new TracksSnapshotCommand(snapshot, currentTracks);
+ if (this.previewOverlay.size === 0) return;
+ const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
+ const afterTracks =
+ this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
+ const command = new TracksSnapshotCommand(committedTracks, afterTracks);
this.editor.command.push({ command });
+ this.previewOverlay.clear();
+ this.previewTracks = null;
+ this.updateTracks(afterTracks);
}
discardPreview(): void {
- const snapshot = this.previewTracker.end();
- if (snapshot !== null) {
- this.updateTracks(snapshot);
- }
+ if (this.previewOverlay.size === 0) return;
+ this.previewOverlay.clear();
+ this.previewTracks = null;
+ this.notify();
+ }
+
+ private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] {
+ if (this.previewOverlay.size === 0) return tracks;
+ return tracks.map((track) => {
+ const hasOverlay = track.elements.some((el) =>
+ this.previewOverlay.has(el.id),
+ );
+ if (!hasOverlay) return track;
+ const newElements = track.elements.map((el) => {
+ const overlay = this.previewOverlay.get(el.id);
+ return overlay ? ({ ...el, ...overlay } as TimelineElement) : el;
+ });
+ return { ...track, elements: newElements } as TimelineTrack;
+ });
}
duplicateElements({
@@ -597,16 +673,25 @@ export class TimelineManager {
return this.editor.scenes.getActiveScene()?.tracks ?? [];
}
+ getRenderTracks(): TimelineTrack[] {
+ if (this.previewTracks !== null) return this.previewTracks;
+ return this.getTracks();
+ }
+
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
updateTracks(newTracks: TimelineTrack[]): void {
+ this.previewOverlay.clear();
+ this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
this.notify();
}
diff --git a/apps/web/src/data/colors/syntax-ui.tsx b/apps/web/src/data/colors/syntax-ui.tsx
index e650b7d2..67e6ab99 100644
--- a/apps/web/src/data/colors/syntax-ui.tsx
+++ b/apps/web/src/data/colors/syntax-ui.tsx
@@ -4,7 +4,6 @@ export const syntaxUIGradients = [
// Cyan to Blue gradients
"linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
"linear-gradient(to right, #bfdbfe, #a5f3fc)",
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
// Purple gradients
"linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
@@ -12,7 +11,6 @@ export const syntaxUIGradients = [
// Blue gradients
"linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
// Green gradients
"linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
index e4f378d6..bfac931b 100644
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -1,15 +1,22 @@
"use client";
+import { useEffect, useRef } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import { getElementsAtTime } from "@/lib/timeline";
+import { cancelInteraction } from "@/lib/cancel-interaction";
+import { invokeAction } from "@/lib/actions";
+import {
+ activateScope,
+ clearActiveScope,
+ type ScopeEntry,
+} from "@/lib/selection/scope";
export function useEditorActions() {
const editor = useEditor();
- const activeProject = editor.project.getActive();
const { selectedElements, setElementSelection } = useElementSelection();
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
const clipboard = useTimelineStore((s) => s.clipboard);
@@ -17,6 +24,39 @@ export function useEditorActions() {
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
+ const hasTimelineSelectionRef = useRef(false);
+ const clearTimelineSelectionRef = useRef(() => {});
+ const timelineScopeRef = useRef(null);
+ const hasTimelineSelection =
+ selectedElements.length > 0 || selectedKeyframes.length > 0;
+
+ hasTimelineSelectionRef.current = hasTimelineSelection;
+ clearTimelineSelectionRef.current = () => {
+ setElementSelection({ elements: [] });
+ clearKeyframeSelection();
+ };
+
+ if (!timelineScopeRef.current) {
+ timelineScopeRef.current = {
+ hasSelection: () => hasTimelineSelectionRef.current,
+ clear: () => {
+ clearTimelineSelectionRef.current();
+ },
+ };
+ }
+
+ useEffect(() => {
+ if (!hasTimelineSelection) {
+ return;
+ }
+
+ const timelineScope = timelineScopeRef.current;
+ if (!timelineScope) {
+ return;
+ }
+
+ return activateScope({ entry: timelineScope });
+ }, [hasTimelineSelection]);
useActionHandler(
"toggle-play",
@@ -65,7 +105,7 @@ export function useEditorActions() {
useActionHandler(
"frame-step-forward",
() => {
- const fps = activeProject.settings.fps;
+ const fps = editor.project.getActive().settings.fps;
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
@@ -79,7 +119,7 @@ export function useEditorActions() {
useActionHandler(
"frame-step-backward",
() => {
- const fps = activeProject.settings.fps;
+ const fps = editor.project.getActive().settings.fps;
editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
});
@@ -240,14 +280,22 @@ export function useEditorActions() {
undefined,
);
+ useActionHandler(
+ "cancel-interaction",
+ () => {
+ if (!cancelInteraction()) {
+ invokeAction("deselect-all");
+ }
+ },
+ undefined,
+ );
+
useActionHandler(
"deselect-all",
() => {
- setElementSelection({ elements: [] });
- clearKeyframeSelection();
- const activeElement = document.activeElement;
- if (activeElement instanceof HTMLButtonElement) {
- activeElement.blur();
+ if (!clearActiveScope()) {
+ setElementSelection({ elements: [] });
+ clearKeyframeSelection();
}
},
undefined,
@@ -353,4 +401,29 @@ export function useEditorActions() {
},
undefined,
);
+
+ // todo: potnetially unify these two actions:
+ useActionHandler(
+ "remove-media-asset",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAsset({
+ projectId: args.projectId,
+ id: args.assetId,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "remove-media-assets",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAssets({
+ projectId: args.projectId,
+ ids: args.assetIds,
+ });
+ },
+ undefined,
+ );
}
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index ea01c718..fae8c527 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -18,16 +18,14 @@ import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
-import {
- snapElementEdge,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
+import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
+import { registerCanceller } from "@/lib/cancel-interaction";
import type {
DropTarget,
ElementDragState,
TimelineElement,
TimelineTrack,
-} from "@/types/timeline";
+} from "@/lib/timeline";
interface UseElementInteractionProps {
zoomLevel: number;
@@ -213,6 +211,20 @@ export function useElementInteraction({
setDragDropTarget(null);
}, []);
+ const cancelCurrentDrag = useCallback(() => {
+ pendingDragRef.current = null;
+ mouseDownLocationRef.current = null;
+ setIsPendingDrag(false);
+ endDrag();
+ onSnapPointChange?.(null);
+ }, [endDrag, onSnapPointChange]);
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ return registerCanceller({ fn: cancelCurrentDrag });
+ }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
+
const getDragSnapResult = useCallback(
({
frameSnappedTime,
@@ -445,33 +457,36 @@ export function useElementInteraction({
return;
}
- if (dropTarget.isNewTrack) {
- const newTrackId = generateUUID();
+ if (dropTarget.isNewTrack) {
+ const newTrackId = generateUUID();
- editor.timeline.moveElement({
- sourceTrackId: dragState.trackId,
- targetTrackId: newTrackId,
- elementId: dragState.elementId,
- newStartTime: snappedTime,
- createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
- rippleEnabled: rippleEditingEnabled,
- });
- selectElement({ trackId: newTrackId, elementId: dragState.elementId });
- } else {
- const targetTrack = tracks[dropTarget.trackIndex];
- if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
- targetTrackId: targetTrack.id,
+ targetTrackId: newTrackId,
elementId: dragState.elementId,
newStartTime: snappedTime,
+ createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
rippleEnabled: rippleEditingEnabled,
});
- if (targetTrack.id !== dragState.trackId) {
- selectElement({ trackId: targetTrack.id, elementId: dragState.elementId });
+ selectElement({ trackId: newTrackId, elementId: dragState.elementId });
+ } else {
+ const targetTrack = tracks[dropTarget.trackIndex];
+ if (targetTrack) {
+ editor.timeline.moveElement({
+ sourceTrackId: dragState.trackId,
+ targetTrackId: targetTrack.id,
+ elementId: dragState.elementId,
+ newStartTime: snappedTime,
+ rippleEnabled: rippleEditingEnabled,
+ });
+ if (targetTrack.id !== dragState.trackId) {
+ selectElement({
+ trackId: targetTrack.id,
+ elementId: dragState.elementId,
+ });
+ }
}
}
- }
endDrag();
onSnapPointChange?.(null);
@@ -520,10 +535,10 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
+ const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
- // right-click: don't stop propagation so ContextMenu can open
- if (isRightClick) {
+ // right-click: don't stop propagation so ContextMenu can open
+ if (isRightClick) {
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
@@ -538,12 +553,12 @@ export function useElementInteraction({
return;
}
- event.stopPropagation();
- mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
+ event.stopPropagation();
+ mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
- const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
+ const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
- if (isMultiSelect) {
+ if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
@@ -551,7 +566,7 @@ export function useElementInteraction({
});
}
- const clickOffsetTime = getClickOffsetTime({
+ const clickOffsetTime = getClickOffsetTime({
clientX: event.clientX,
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
@@ -579,9 +594,9 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- event.stopPropagation();
+ event.stopPropagation();
- if (mouseDownLocationRef.current) {
+ if (mouseDownLocationRef.current) {
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
@@ -593,7 +608,7 @@ export function useElementInteraction({
// modifier keys already handled in mousedown
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
- const alreadySelected = isElementSelected({
+ const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 3f49e798..b41f82f9 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
@@ -9,7 +9,13 @@ import {
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
+import { isRetimableElement } from "@/lib/timeline";
+import {
+ getSourceSpanAtClipTime,
+ getTimelineDurationForSourceSpan,
+} from "@/lib/retime";
import { useTimelineStore } from "@/stores/timeline-store";
+import { registerCanceller } from "@/lib/cancel-interaction";
export interface ResizeState {
elementId: string;
@@ -37,7 +43,6 @@ export function useTimelineElementResize({
onResizeStateChange,
}: UseTimelineElementResizeProps) {
const editor = useEditor();
- const activeProject = editor.project.getActive();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const rippleEditingEnabled = useTimelineStore(
@@ -91,6 +96,94 @@ export function useTimelineElementResize({
return element.sourceDuration == null;
}, [element.sourceDuration]);
+ const getSourceDeltaForClipDelta = useCallback(
+ (clipDelta: number) => {
+ if (!isRetimableElement(element)) {
+ return clipDelta;
+ }
+
+ return clipDelta >= 0
+ ? getSourceSpanAtClipTime({
+ clipTime: clipDelta,
+ retime: element.retime,
+ })
+ : -getSourceSpanAtClipTime({
+ clipTime: Math.abs(clipDelta),
+ retime: element.retime,
+ });
+ },
+ [element],
+ );
+
+ const getVisibleSourceSpanForDuration = useCallback(
+ (duration: number) => {
+ if (!isRetimableElement(element)) {
+ return duration;
+ }
+
+ return getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime: element.retime,
+ });
+ },
+ [element],
+ );
+
+ const getDurationForVisibleSourceSpan = useCallback(
+ (sourceSpan: number) => {
+ if (!isRetimableElement(element)) {
+ return sourceSpan;
+ }
+
+ return getTimelineDurationForSourceSpan({
+ sourceSpan,
+ retime: element.retime,
+ });
+ },
+ [element],
+ );
+
+ const getSourceDuration = useCallback(
+ ({
+ trimStart,
+ duration,
+ trimEnd,
+ }: {
+ trimStart: number;
+ duration: number;
+ trimEnd: number;
+ }) => {
+ if (typeof element.sourceDuration === "number") {
+ return element.sourceDuration;
+ }
+
+ return trimStart + getVisibleSourceSpanForDuration(duration) + trimEnd;
+ },
+ [element.sourceDuration, getVisibleSourceSpanForDuration],
+ );
+
+ const cancelResize = useCallback(() => {
+ if (!resizing) return;
+
+ setCurrentTrimStart(resizing.initialTrimStart);
+ setCurrentTrimEnd(resizing.initialTrimEnd);
+ setCurrentStartTime(resizing.initialStartTime);
+ setCurrentDuration(resizing.initialDuration);
+ currentTrimStartRef.current = resizing.initialTrimStart;
+ currentTrimEndRef.current = resizing.initialTrimEnd;
+ currentStartTimeRef.current = resizing.initialStartTime;
+ currentDurationRef.current = resizing.initialDuration;
+ setResizing(null);
+ onResizeStateChange?.({ isResizing: false });
+ onSnapPointChange?.(null);
+ }, [resizing, onResizeStateChange, onSnapPointChange]);
+
+ useEffect(() => {
+ if (!resizing) return;
+
+ return registerCanceller({ fn: cancelResize });
+ }, [resizing, cancelResize]);
+
const updateTrimFromMouseMove = useCallback(
({ clientX }: { clientX: number }) => {
if (!resizing) return;
@@ -100,7 +193,7 @@ export function useTimelineElementResize({
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
- const projectFps = activeProject.settings.fps;
+ const projectFps = editor.project.getActive().settings.fps;
const minDurationSeconds = 1 / projectFps;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
@@ -139,14 +232,20 @@ export function useTimelineElementResize({
}
onSnapPointChange?.(resizeSnapPoint);
- const otherElements = track.elements.filter(({ id }) => id !== element.id);
- const initialEndTime = resizing.initialStartTime + resizing.initialDuration;
+ const otherElements = track.elements.filter(
+ ({ id }) => id !== element.id,
+ );
+ const initialEndTime =
+ resizing.initialStartTime + resizing.initialDuration;
const rightNeighborBound =
resizing.side === "right"
? otherElements
.filter(({ startTime }) => startTime >= initialEndTime)
- .reduce((min, { startTime }) => Math.min(min, startTime), Infinity)
+ .reduce(
+ (min, { startTime }) => Math.min(min, startTime),
+ Infinity,
+ )
: Infinity;
const leftNeighborBound =
@@ -164,20 +263,26 @@ export function useTimelineElementResize({
: -Infinity;
if (resizing.side === "left") {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
+ const sourceDuration = getSourceDuration({
+ trimStart: resizing.initialTrimStart,
+ duration: resizing.initialDuration,
+ trimEnd: resizing.initialTrimEnd,
+ });
const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
? Math.max(
0,
resizing.initialTrimStart +
- (leftNeighborBound - resizing.initialStartTime),
+ getSourceDeltaForClipDelta(
+ leftNeighborBound - resizing.initialStartTime,
+ ),
)
: 0;
const maxAllowed =
- sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
- const calculated = resizing.initialTrimStart + deltaTime;
+ sourceDuration -
+ resizing.initialTrimEnd -
+ getVisibleSourceSpanForDuration(minDurationSeconds);
+ const calculated =
+ resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
@@ -187,13 +292,17 @@ export function useTimelineElementResize({
),
fps: projectFps,
});
- const trimDelta = newTrimStart - resizing.initialTrimStart;
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime + trimDelta,
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - newTrimStart - resizing.initialTrimEnd,
+ );
+ const newDuration = snapTimeToFrame({
+ time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
+ const trimDelta = resizing.initialDuration - newDuration;
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
@@ -233,31 +342,50 @@ export function useTimelineElementResize({
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
- const trimDelta =
+ const leftBound = Number.isFinite(leftNeighborBound)
+ ? leftNeighborBound
+ : 0;
+ const trimDeltaFromTrimStart =
minTrimStartForNeighbor - resizing.initialTrimStart;
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime + trimDelta,
+ const trimDeltaFromStartTime = getSourceDeltaForClipDelta(
+ leftBound - resizing.initialStartTime,
+ );
+ const trimDelta = Math.max(
+ trimDeltaFromTrimStart,
+ trimDeltaFromStartTime,
+ );
+ const newTrimStart = resizing.initialTrimStart + trimDelta;
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - newTrimStart - resizing.initialTrimEnd,
+ );
+ const newDuration = snapTimeToFrame({
+ time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
+ const newStartTime = snapTimeToFrame({
+ time:
+ resizing.initialStartTime +
+ (resizing.initialDuration - newDuration),
fps: projectFps,
});
- setCurrentTrimStart(minTrimStartForNeighbor);
+ setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
- currentTrimStartRef.current = minTrimStartForNeighbor;
+ currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
- const newTrimEnd = resizing.initialTrimEnd - deltaTime;
+ const sourceDuration = getSourceDuration({
+ trimStart: resizing.initialTrimStart,
+ duration: resizing.initialDuration,
+ trimEnd: resizing.initialTrimEnd,
+ });
+ const newTrimEnd =
+ resizing.initialTrimEnd - getSourceDeltaForClipDelta(deltaTime);
const maxAllowedDuration = Number.isFinite(rightNeighborBound)
? rightNeighborBound - resizing.initialStartTime
: Infinity;
@@ -268,7 +396,10 @@ export function useTimelineElementResize({
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
- time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration),
+ time: Math.min(
+ baseDuration + extensionNeeded,
+ maxAllowedDuration,
+ ),
fps: projectFps,
});
@@ -277,12 +408,11 @@ export function useTimelineElementResize({
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
- const extensionToLimit = resizing.initialTrimEnd;
+ const unclampedDuration = getDurationForVisibleSourceSpan(
+ Math.max(0, sourceDuration - resizing.initialTrimStart),
+ );
const newDuration = snapTimeToFrame({
- time: Math.min(
- resizing.initialDuration + extensionToLimit,
- maxAllowedDuration,
- ),
+ time: Math.min(unclampedDuration, maxAllowedDuration),
fps: projectFps,
});
@@ -295,13 +425,15 @@ export function useTimelineElementResize({
const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
? Math.max(
0,
- resizing.initialDuration +
- resizing.initialTrimEnd -
- maxAllowedDuration,
+ sourceDuration -
+ resizing.initialTrimStart -
+ getVisibleSourceSpanForDuration(maxAllowedDuration),
)
: 0;
const maxTrimEnd =
- sourceDuration - resizing.initialTrimStart - minDurationSeconds;
+ sourceDuration -
+ resizing.initialTrimStart -
+ getVisibleSourceSpanForDuration(minDurationSeconds);
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
@@ -310,9 +442,12 @@ export function useTimelineElementResize({
time: clampedTrimEnd,
fps: projectFps,
});
- const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - resizing.initialTrimStart - finalTrimEnd,
+ );
const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
+ time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
@@ -326,13 +461,16 @@ export function useTimelineElementResize({
[
resizing,
zoomLevel,
- activeProject.settings.fps,
snappingEnabled,
editor,
element.id,
track.elements,
onSnapPointChange,
canExtendElementDuration,
+ getDurationForVisibleSourceSpan,
+ getSourceDeltaForClipDelta,
+ getSourceDuration,
+ getVisibleSourceSpanForDuration,
isShiftHeldRef,
],
);
@@ -349,7 +487,12 @@ export function useTimelineElementResize({
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
const durationChanged = finalDuration !== resizing.initialDuration;
- if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) {
+ if (
+ trimStartChanged ||
+ trimEndChanged ||
+ startTimeChanged ||
+ durationChanged
+ ) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts
index c1e5b53f..dcfb5720 100644
--- a/apps/web/src/hooks/timeline/element/use-element-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts
@@ -1,14 +1,10 @@
-import { useCallback, useSyncExternalStore } from "react";
+import { useCallback } from "react";
import { useEditor } from "@/hooks/use-editor";
-
-type ElementRef = { trackId: string; elementId: string };
+import type { ElementRef } from "@/lib/timeline/types";
export function useElementSelection() {
const editor = useEditor();
- const selectedElements = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedElements(),
- );
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
const isElementSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
@@ -82,6 +78,30 @@ export function useElementSelection() {
[editor],
);
+
+ /**
+ * Merges elements into the current selection, deduplicating by identity.
+ * Used for additive box-select where the pre-drag selection is preserved.
+ */
+ const mergeElementsIntoSelection = useCallback(
+ ({ elements }: { elements: ElementRef[] }) => {
+ const merged = [
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !elements.some(
+ (element) =>
+ element.trackId === selectedElement.trackId &&
+ element.elementId === selectedElement.elementId,
+ ),
+ ),
+ ...elements,
+ ];
+ editor.selection.setSelectedElements({ elements: merged });
+ },
+ [selectedElements, editor],
+ );
+
+
/**
* Handles click interaction on an element.
* - Regular click: select only this element
@@ -107,6 +127,7 @@ export function useElementSelection() {
isElementSelected,
selectElement,
setElementSelection,
+ mergeElementsIntoSelection,
addElementToSelection,
removeElementFromSelection,
toggleElementSelection,
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
index b18db6eb..4f961a0a 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
@@ -15,9 +15,10 @@ import {
} from "@/constants/timeline-constants";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands";
-import type { SelectedKeyframeRef } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
import type { Command } from "@/lib/commands/base-command";
+import { registerCanceller } from "@/lib/cancel-interaction";
export interface KeyframeDragState {
isDragging: boolean;
draggingKeyframeIds: Set;
@@ -69,6 +70,13 @@ export function useKeyframeDrag({
setDragState(initialDragState);
}, []);
+ const cancelDrag = useCallback(() => {
+ pendingDragRef.current = null;
+ mouseDownXRef.current = null;
+ setIsPendingDrag(false);
+ endDrag();
+ }, [endDrag]);
+
const commitDrag = useCallback(
({
keyframeRefs,
@@ -107,6 +115,12 @@ export function useKeyframeDrag({
[editor.command, element],
);
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ return registerCanceller({ fn: cancelDrag });
+ }, [dragState.isDragging, isPendingDrag, cancelDrag]);
+
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
index 0ab05fe3..8634cb48 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
@@ -1,6 +1,6 @@
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
-import type { SelectedKeyframeRef } from "@/types/animation";
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
function getSelectedKeyframeId({
keyframe,
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
index 0e7fabd5..512db821 100644
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -15,7 +15,7 @@ import {
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
-import type { Bookmark } from "@/types/timeline";
+import type { Bookmark } from "@/lib/timeline";
export interface BookmarkDragState {
isDragging: boolean;
diff --git a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
new file mode 100644
index 00000000..50833f14
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
@@ -0,0 +1,42 @@
+import { type RefObject, useLayoutEffect, useRef } from "react";
+
+interface UseInitialScrollBottomProps {
+ tracksScrollRef: RefObject;
+ trackLabelsScrollRef: RefObject;
+ onAfterScroll?: () => void;
+ /** Defers the scroll until there is at least one track to measure against. */
+ isReady: boolean;
+}
+
+/**
+ * Scrolls the timeline tracks viewport to the bottom once on mount.
+ * useLayoutEffect runs synchronously after React commits the DOM but before
+ * the browser paints, so the initial scroll position is never visible.
+ */
+export function useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll,
+ isReady,
+}: UseInitialScrollBottomProps): void {
+ const hasScrolledRef = useRef(false);
+
+ useLayoutEffect(() => {
+ if (!isReady || hasScrolledRef.current) return;
+
+ const viewport = tracksScrollRef.current;
+ if (!viewport) return;
+
+ const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
+ if (maxScrollTop <= 0) return;
+
+ viewport.scrollTop = maxScrollTop;
+
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = maxScrollTop;
+ }
+
+ onAfterScroll?.();
+ hasScrolledRef.current = true;
+ }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]);
+}
diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
index f3e63894..1a8f3c5d 100644
--- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
+++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
@@ -1,13 +1,10 @@
import { useEffect, useState } from "react";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
-
+import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
- tracks: TimelineTrack[];
timelineRef: React.RefObject;
- trackLabelsRef?: React.RefObject;
tracksScrollRef: React.RefObject;
}
@@ -20,9 +17,7 @@ interface SnapIndicatorPosition {
export function useSnapIndicatorPosition({
snapPoint,
zoomLevel,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
const [scrollLeft, setScrollLeft] = useState(0);
@@ -45,16 +40,11 @@ export function useSnapIndicatorPosition({
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
- const trackLabelsWidth =
- tracks.length > 0 && trackLabelsRef?.current
- ? trackLabelsRef.current.offsetWidth
- : 0;
-
const timelinePosition = timelineTimeToSnappedPixels({
time: snapPoint?.time ?? 0,
zoomLevel,
});
- const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
+ const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft;
return {
leftPosition,
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 8a30785e..e8f6e49d 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -2,26 +2,28 @@ import { useState, useCallback, type RefObject } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import {
buildTextElement,
+ buildGraphicElement,
buildStickerElement,
buildElementFromMedia,
buildEffectElement,
} from "@/lib/timeline/element-utils";
-import type { Command } from "@/lib/commands/base-command";
-import { AddMediaAssetCommand } from "@/lib/commands/media";
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
-import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
+import { isMainTrack } from "@/lib/timeline/track-utils";
+import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type {
MediaDragData,
+ GraphicDragData,
StickerDragData,
EffectDragData,
-} from "@/types/drag";
+} from "@/lib/timeline/drag";
interface UseTimelineDragDropProps {
containerRef: RefObject;
@@ -41,17 +43,12 @@ export function useTimelineDragDrop({
const [dropTarget, setDropTarget] = useState(null);
const [dragElementType, setElementType] = useState(null);
- const tracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
- const mediaAssets = editor.media.getAssets();
- const activeProject = editor.project.getActive();
-
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
- const projectFps = activeProject.settings.fps;
+ const projectFps = editor.project.getActive().settings.fps;
return snapTimeToFrame({ time, fps: projectFps });
},
- [activeProject.settings.fps],
+ [editor],
);
const getElementType = useCallback(
@@ -60,6 +57,7 @@ export function useTimelineDragDrop({
if (!dragData) return null;
if (dragData.type === "text") return "text";
+ if (dragData.type === "graphic") return "graphic";
if (dragData.type === "sticker") return "sticker";
if (dragData.type === "effect") return "effect";
if (dragData.type === "media") {
@@ -80,18 +78,20 @@ export function useTimelineDragDrop({
}): number => {
if (
elementType === "text" ||
+ elementType === "graphic" ||
elementType === "sticker" ||
elementType === "effect"
) {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
if (mediaId) {
+ const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId);
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
},
- [mediaAssets],
+ [editor],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
@@ -148,6 +148,8 @@ export function useTimelineDragDrop({
? (dragData as MediaDragData).targetElementTypes
: undefined;
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
const target = computeDropTarget({
elementType,
mouseX,
@@ -170,12 +172,11 @@ export function useTimelineDragDrop({
containerRef,
headerRef,
tracksScrollRef,
- tracks,
- currentTime,
zoomLevel,
getElementType,
getElementDuration,
getSnappedTime,
+ editor,
],
);
@@ -228,6 +229,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@@ -235,7 +237,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, tracks],
+ [editor],
);
const executeStickerDrop = useCallback(
@@ -253,7 +255,7 @@ export function useTimelineDragDrop({
});
if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex);
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
@@ -264,6 +266,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@@ -271,7 +274,45 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, tracks],
+ [editor],
+ );
+
+ const executeGraphicDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: GraphicDragData;
+ }) => {
+ const element = buildGraphicElement({
+ definitionId: dragData.definitionId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ params: dragData.params,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
);
const executeMediaDrop = useCallback(
@@ -281,6 +322,7 @@ export function useTimelineDragDrop({
return;
}
+ const mediaAssets = editor.media.getAssets();
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
if (!mediaAsset) return;
@@ -309,6 +351,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@@ -316,7 +359,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, mediaAssets, tracks],
+ [editor],
);
const executeEffectDrop = useCallback(
@@ -336,6 +379,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const effectTrack = tracks.find((t) => t.type === "effect");
let trackId: string;
@@ -370,7 +414,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, tracks],
+ [editor],
);
const executeFileDrop = useCallback(
@@ -383,70 +427,98 @@ export function useTimelineDragDrop({
mouseX: number;
mouseY: number;
}) => {
+ const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return;
- const processedAssets = await processMediaAssets({ files });
- const projectId = activeProject.metadata.id;
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const projectId = activeProject.metadata.id;
- for (const asset of processedAssets) {
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const currentTracks = editor.timeline.getTracks();
- const dropTarget = computeDropTarget({
- elementType: asset.type,
- mouseX,
- mouseY,
- tracks: currentTracks,
- playheadTime: currentTime,
- isExternalDrop: true,
- elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
- zoomLevel,
- });
+ for (const asset of processedAssets) {
+ const createdAsset = await editor.media.addMediaAsset({
+ projectId,
+ asset,
+ });
+ if (!createdAsset) continue;
- const trackType: TrackType = asset.type === "audio" ? "audio" : "video";
- const addMediaCmd = new AddMediaAssetCommand(projectId, asset);
- const assetId = addMediaCmd.getAssetId();
+ const duration =
+ createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const currentTracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const onlyTrack = currentTracks[0];
+ const reuseMainTrackId =
+ createdAsset.type !== "audio" &&
+ currentTracks.length === 1 &&
+ !!onlyTrack &&
+ isMainTrack(onlyTrack) &&
+ onlyTrack.elements.length === 0
+ ? onlyTrack.id
+ : null;
+ const dropTarget = reuseMainTrackId
+ ? null
+ : computeDropTarget({
+ elementType: createdAsset.type,
+ mouseX,
+ mouseY,
+ tracks: currentTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ });
- const commands: Command[] = [addMediaCmd];
+ const trackType: TrackType =
+ createdAsset.type === "audio" ? "audio" : "video";
- let trackId: string | undefined;
- if (dropTarget.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(
- trackType,
- dropTarget.trackIndex,
- );
- trackId = addTrackCmd.getTrackId();
- commands.unshift(addTrackCmd);
- } else {
- trackId = currentTracks[dropTarget.trackIndex]?.id;
- }
+ let trackId: string | undefined;
+ if (reuseMainTrackId) {
+ trackId = reuseMainTrackId;
+ } else {
+ if (!dropTarget) continue;
+ if (dropTarget.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(
+ trackType,
+ dropTarget.trackIndex,
+ );
+ trackId = addTrackCmd.getTrackId();
+ editor.command.execute({ command: addTrackCmd });
+ } else {
+ trackId = currentTracks[dropTarget.trackIndex]?.id;
+ }
+ }
- if (!trackId) return;
+ if (!trackId) continue;
- const element = buildElementFromMedia({
- mediaId: assetId,
- mediaType: asset.type,
- name: asset.name,
- duration,
- startTime: dropTarget.xPosition,
- buffer:
- asset.type === "audio"
- ? new AudioBuffer({ length: 1, sampleRate: 44100 })
- : undefined,
- });
+ const element = buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: dropTarget?.xPosition ?? currentTime,
+ buffer:
+ createdAsset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId },
- });
- commands.push(insertCmd);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId },
+ });
+ editor.command.execute({ command: insertCmd });
+ }
- const batchCmd = new BatchCommand(commands);
- editor.command.execute({ command: batchCmd });
- }
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
},
- [activeProject, editor.command, editor.timeline, currentTime, zoomLevel],
+ [editor, zoomLevel],
);
const handleDrop = useCallback(
@@ -471,6 +543,11 @@ export function useTimelineDragDrop({
if (dragData.type === "text") {
executeTextDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "graphic") {
+ executeGraphicDrop({
+ target: currentTarget,
+ dragData: dragData as GraphicDragData,
+ });
} else if (dragData.type === "sticker") {
executeStickerDrop({ target: currentTarget, dragData });
} else if (dragData.type === "effect") {
@@ -502,12 +579,12 @@ export function useTimelineDragDrop({
}
} catch (err) {
console.error("Failed to process drop:", err);
- toast.error("Failed to process drop");
}
},
[
dropTarget,
executeTextDrop,
+ executeGraphicDrop,
executeStickerDrop,
executeMediaDrop,
executeEffectDrop,
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index 1426a003..e6204f23 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -1,12 +1,13 @@
import { getSnappedSeekTime } from "@/lib/time";
-import { useState, useEffect, useCallback, useRef } from "react";
+import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
+import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
import {
- findSnapPoints,
- snapToNearestPoint,
-} from "@/lib/timeline/snap-utils";
+ getCenteredLineLeft,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@@ -25,27 +26,33 @@ export function useTimelinePlayhead({
playheadRef,
}: UseTimelinePlayheadProps) {
const editor = useEditor();
+ const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
const activeProject = editor.project.getActive();
- const currentTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
- const isPlaying = editor.playback.getIsPlaying();
- const isScrubbing = editor.playback.getIsScrubbing();
const isShiftHeldRef = useShiftKey();
+ const zoomLevelRef = useRef(zoomLevel);
+ const durationRef = useRef(duration);
+ const isScrubbingRef = useRef(isScrubbing);
+ const isPlayingRef = useRef(false);
+
+ useEffect(() => {
+ zoomLevelRef.current = zoomLevel;
+ durationRef.current = duration;
+ isScrubbingRef.current = isScrubbing;
+ isPlayingRef.current = editor.playback.getIsPlaying();
+ }, [zoomLevel, duration, isScrubbing, editor.playback]);
+
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
[editor.playback],
);
- const [scrubTime, setScrubTime] = useState(null);
-
- const [isDraggingRuler, setIsDraggingRuler] = useState(false);
- const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
+ const scrubTimeRef = useRef(null);
+ const isDraggingRulerRef = useRef(false);
+ const hasDraggedRulerRef = useRef(false);
const lastMouseXRef = useRef(0);
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
const handleScrub = useCallback(
({
event,
@@ -86,8 +93,7 @@ export function useTimelinePlayhead({
const time = (() => {
if (!shouldSnap) return frameTime;
const tracks = editor.timeline.getTracks();
- const bookmarks =
- editor.scenes.getActiveScene()?.bookmarks ?? [];
+ const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
const snapPoints = findSnapPoints({
tracks,
playheadTime: frameTime,
@@ -102,7 +108,7 @@ export function useTimelinePlayhead({
return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
})();
- setScrubTime(time);
+ scrubTimeRef.current = time;
seek({ time });
lastMouseXRef.current = event.clientX;
@@ -132,17 +138,16 @@ export function useTimelinePlayhead({
const handleRulerMouseDown = useCallback(
({ event }: { event: React.MouseEvent }) => {
if (event.button !== 0) return;
-
if (playheadRef?.current?.contains(event.target as Node)) return;
event.preventDefault();
- setIsDraggingRuler(true);
- setHasDraggedRuler(false);
+ isDraggingRulerRef.current = true;
+ hasDraggedRulerRef.current = false;
- editor.playback.setScrubbing({ isScrubbing: true });
- handleScrub({ event, snappingEnabled: false });
- },
- [handleScrub, playheadRef, editor.playback],
+ editor.playback.setScrubbing({ isScrubbing: true });
+ handleScrub({ event, snappingEnabled: false });
+ },
+ [handleScrub, playheadRef, editor.playback],
);
const handlePlayheadMouseDownEvent = useCallback(
@@ -168,31 +173,32 @@ export function useTimelinePlayhead({
const handleMouseMove = ({ event }: { event: MouseEvent }) => {
handleScrub({ event });
- if (isDraggingRuler) {
- setHasDraggedRuler(true);
+ if (isDraggingRulerRef.current) {
+ hasDraggedRulerRef.current = true;
}
};
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
editor.playback.setScrubbing({ isScrubbing: false });
- if (scrubTime !== null) {
- seek({ time: scrubTime });
+ const finalTime = scrubTimeRef.current;
+ if (finalTime !== null) {
+ seek({ time: finalTime });
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
- playheadTime: scrubTime,
+ playheadTime: finalTime,
},
});
}
- setScrubTime(null);
+ scrubTimeRef.current = null;
- if (isDraggingRuler) {
- setIsDraggingRuler(false);
- if (!hasDraggedRuler) {
+ if (isDraggingRulerRef.current) {
+ isDraggingRulerRef.current = false;
+ if (!hasDraggedRulerRef.current) {
handleScrub({ event, snappingEnabled: false });
}
- setHasDraggedRuler(false);
+ hasDraggedRulerRef.current = false;
}
};
@@ -206,55 +212,79 @@ export function useTimelinePlayhead({
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
- }, [
- isScrubbing,
- scrubTime,
- seek,
- handleScrub,
- isDraggingRuler,
- hasDraggedRuler,
- editor,
- tracksScrollRef,
- zoomLevel,
- ]);
+ }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
+
+ const updatePlayheadLeft = useCallback(
+ (time: number) => {
+ const playheadEl = playheadRef?.current;
+ if (!playheadEl) return;
+ const centerPosition = timelineTimeToSnappedPixels({
+ time,
+ zoomLevel: zoomLevelRef.current,
+ });
+ const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
+ const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0;
+ playheadEl.style.left = `${leftPosition - scrollLeft}px`;
+ },
+ [playheadRef, rulerScrollRef],
+ );
useEffect(() => {
- if (!isPlaying || isScrubbing) return;
+ const scrollEl = rulerScrollRef.current;
+ if (!scrollEl) return;
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) return;
+ const handleScroll = () => {
+ updatePlayheadLeft(editor.playback.getCurrentTime());
+ };
- const playheadPixels =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMinimum = 0;
- const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+ scrollEl.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollEl.removeEventListener("scroll", handleScroll);
+ }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
- const needsScroll =
- playheadPixels < rulerViewport.scrollLeft ||
- playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+ useEffect(() => {
+ const handlePlaybackUpdate = (e: Event) => {
+ const time = (e as CustomEvent<{ time: number }>).detail.time;
+ updatePlayheadLeft(time);
- if (needsScroll) {
- const desiredScroll = Math.max(
- scrollMinimum,
- Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
- );
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [
- playheadPosition,
- zoomLevel,
- rulerScrollRef,
- tracksScrollRef,
- isScrubbing,
- isPlaying,
- ]);
+ if (!isPlayingRef.current || isScrubbingRef.current) return;
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) return;
+
+ const playheadPixels =
+ time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current;
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMinimum = 0;
+ const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+
+ const needsScroll =
+ playheadPixels < rulerViewport.scrollLeft ||
+ playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+
+ if (needsScroll) {
+ const desiredScroll = Math.max(
+ scrollMinimum,
+ Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
+ );
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ };
+
+ const initialTime = editor.playback.getCurrentTime();
+ handlePlaybackUpdate({
+ detail: { time: initialTime },
+ } as CustomEvent<{ time: number }>);
+
+ window.addEventListener("playback-update", handlePlaybackUpdate);
+ window.addEventListener("playback-seek", handlePlaybackUpdate);
+ return () => {
+ window.removeEventListener("playback-update", handlePlaybackUpdate);
+ window.removeEventListener("playback-seek", handlePlaybackUpdate);
+ };
+ }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
return {
- playheadPosition,
handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
handleRulerMouseDown: handleRulerMouseDownEvent,
- isDraggingRuler,
};
}
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
index 095c64dc..64a0fb58 100644
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
@@ -81,18 +81,22 @@ export function useTimelineZoom({
return;
}
- // pinch-zoom (ctrl/meta + wheel)
- if (isZoomGesture) {
- const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
- setZoomLevel((prev) => {
- const nextZoom = Math.max(
- minZoom,
- Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
- );
- return nextZoom;
- });
- return;
- }
+ // pinch-zoom (ctrl/meta + wheel)
+ if (isZoomGesture) {
+ const normalizedDelta =
+ event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
+ const cappedDelta =
+ Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ setZoomLevel((prev) => {
+ const nextZoom = Math.max(
+ minZoom,
+ Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomFactor),
+ );
+ return nextZoom;
+ });
+ return;
+ }
},
[minZoom, setZoomLevel],
);
diff --git a/apps/web/src/hooks/use-container-size.ts b/apps/web/src/hooks/use-container-size.ts
index 8b46f20e..6f6f89be 100644
--- a/apps/web/src/hooks/use-container-size.ts
+++ b/apps/web/src/hooks/use-container-size.ts
@@ -1,4 +1,5 @@
-import { useEffect, useState } from "react";
+import { useCallback, useState } from "react";
+import { useResizeObserver } from "./use-resize-observer";
export function useContainerSize({
containerRef,
@@ -7,23 +8,12 @@ export function useContainerSize({
}) {
const [size, setSize] = useState({ width: 0, height: 0 });
- useEffect(() => {
- const container = containerRef.current;
- if (!container) return;
+ const onResize = useCallback((entry: ResizeObserverEntry) => {
+ const { width, height } = entry.contentRect;
+ setSize({ width, height });
+ }, []);
- const observer = new ResizeObserver((entries) => {
- for (const entry of entries) {
- const { width, height } = entry.contentRect;
- setSize({ width, height });
- }
- });
-
- observer.observe(container);
-
- return () => {
- observer.disconnect();
- };
- }, [containerRef]);
+ useResizeObserver({ ref: containerRef, onResize });
return size;
}
diff --git a/apps/web/src/hooks/use-editor.ts b/apps/web/src/hooks/use-editor.ts
index ff3e2db1..e211a85b 100644
--- a/apps/web/src/hooks/use-editor.ts
+++ b/apps/web/src/hooks/use-editor.ts
@@ -1,39 +1,58 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { EditorCore } from "@/core";
-export function useEditor(): EditorCore {
+function isShallowEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true;
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
+ if (a.length !== b.length) return false;
+ return a.every((item, i) => Object.is(item, b[i]));
+}
+
+const subscribeNone = () => () => {};
+
+export function useEditor(): EditorCore;
+export function useEditor(selector: (editor: EditorCore) => T): T;
+export function useEditor(
+ selector?: (editor: EditorCore) => T,
+): EditorCore | T {
const editor = useMemo(() => EditorCore.getInstance(), []);
- const versionRef = useRef(0);
+ const selectorRef = useRef(selector);
+ selectorRef.current = selector;
- const subscribe = useCallback(
- (onStoreChange: () => void) => {
- const handleStoreChange = () => {
- versionRef.current += 1;
- onStoreChange();
- };
+ const snapshotCacheRef = useRef(undefined);
+ const subscribeAll = useCallback(
+ (onChange: () => void) => {
const unsubscribers = [
- editor.playback.subscribe(handleStoreChange),
- editor.timeline.subscribe(handleStoreChange),
- editor.scenes.subscribe(handleStoreChange),
- editor.project.subscribe(handleStoreChange),
- editor.media.subscribe(handleStoreChange),
- editor.renderer.subscribe(handleStoreChange),
- editor.selection.subscribe(handleStoreChange),
+ editor.playback.subscribe(onChange),
+ editor.timeline.subscribe(onChange),
+ editor.scenes.subscribe(onChange),
+ editor.project.subscribe(onChange),
+ editor.media.subscribe(onChange),
+ editor.renderer.subscribe(onChange),
+ editor.selection.subscribe(onChange),
];
-
return () => {
- for (const unsubscribe of unsubscribers) {
+ unsubscribers.forEach((unsubscribe) => {
unsubscribe();
- }
+ });
};
},
[editor],
);
- const getSnapshot = useCallback(() => versionRef.current, []);
+ const getSnapshot = useCallback(() => {
+ const next = selectorRef.current ? selectorRef.current(editor) : editor;
+ if (isShallowEqual(snapshotCacheRef.current, next)) {
+ return snapshotCacheRef.current;
+ }
+ snapshotCacheRef.current = next;
+ return next;
+ }, [editor]);
- useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
-
- return editor;
+ return useSyncExternalStore(
+ selector ? subscribeAll : subscribeNone,
+ getSnapshot,
+ getSnapshot,
+ ) as EditorCore | T;
}
diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts
deleted file mode 100644
index a84781e0..00000000
--- a/apps/web/src/hooks/use-effect-preview.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { useEffect, useRef } from "react";
-import { effectPreviewService } from "@/services/renderer/effect-preview";
-import type { EffectParamValues } from "@/types/effects";
-
-export function useEffectPreview({
- effectType,
- params,
- canvasRef,
- isActive,
-}: {
- effectType: string;
- params: EffectParamValues;
- canvasRef: React.RefObject;
- isActive: boolean;
-}): void {
- const requestRef = useRef(0);
-
- useEffect(() => {
- if (!isActive) {
- if (requestRef.current) {
- cancelAnimationFrame(requestRef.current);
- requestRef.current = 0;
- }
- return;
- }
-
- const loop = (): void => {
- const canvas = canvasRef.current;
- if (canvas) {
- effectPreviewService.renderPreview({
- effectType,
- params,
- targetCanvas: canvas,
- });
- }
- requestRef.current = requestAnimationFrame(loop);
- };
-
- requestRef.current = requestAnimationFrame(loop);
-
- return () => {
- if (requestRef.current) {
- cancelAnimationFrame(requestRef.current);
- }
- };
- }, [effectType, params, canvasRef, isActive]);
-}
diff --git a/apps/web/src/hooks/use-element-preview.ts b/apps/web/src/hooks/use-element-preview.ts
new file mode 100644
index 00000000..8aa16501
--- /dev/null
+++ b/apps/web/src/hooks/use-element-preview.ts
@@ -0,0 +1,38 @@
+import { useEditor } from "@/hooks/use-editor";
+import type { TimelineElement } from "@/lib/timeline";
+
+/**
+ * Subscribes to render tracks and returns the live (preview-aware) version of
+ * an element alongside helpers for previewing and committing updates.
+ *
+ * Use this wherever property fields need to reflect in-progress preview state
+ * (e.g. a slider being dragged) rather than the last committed value.
+ */
+export function useElementPreview({
+ trackId,
+ elementId,
+ fallback,
+}: {
+ trackId: string;
+ elementId: string;
+ fallback: T;
+}) {
+ const editor = useEditor();
+ useEditor((e) => e.timeline.getRenderTracks());
+
+ const renderElement =
+ (editor.timeline
+ .getRenderTracks()
+ .find((t) => t.id === trackId)
+ ?.elements.find((el) => el.id === elementId) as T | undefined) ??
+ fallback;
+
+ const previewUpdates = (updates: Partial) =>
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates }],
+ });
+
+ const commit = () => editor.timeline.commitPreview();
+
+ return { renderElement, previewUpdates, commit };
+}
diff --git a/apps/web/src/hooks/use-file-upload.ts b/apps/web/src/hooks/use-file-upload.ts
index 19badd98..d174e002 100644
--- a/apps/web/src/hooks/use-file-upload.ts
+++ b/apps/web/src/hooks/use-file-upload.ts
@@ -4,7 +4,7 @@ import { hasDragData } from "@/lib/drag-data";
interface UseFileUploadOptions {
accept?: string;
multiple?: boolean;
- onFilesSelected?: (files: FileList) => void;
+ onFilesSelected?: (files: File[]) => void;
}
function containsFiles(dataTransfer: DataTransfer): boolean {
@@ -29,8 +29,8 @@ export function useFileUpload({
}
function handleFileChange(event: React.ChangeEvent) {
- const files = event.target.files;
- if (files && files.length > 0 && onFilesSelected) {
+ const files = Array.from(event.target.files ?? []);
+ if (files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
@@ -71,15 +71,13 @@ export function useFileUpload({
dragCounterRef.current = 0;
if (onFilesSelected && containsFiles(e.dataTransfer)) {
- const files = e.dataTransfer.files;
+ const files = Array.from(e.dataTransfer.files);
const shouldUseMultiple = multiple ?? false;
if (shouldUseMultiple) {
onFilesSelected(files);
} else if (files.length > 0) {
- const dataTransfer = new DataTransfer();
- dataTransfer.items.add(files[0]);
- onFilesSelected(dataTransfer.files);
+ onFilesSelected([files[0]]);
}
}
}
diff --git a/apps/web/src/hooks/use-font-atlas.ts b/apps/web/src/hooks/use-font-atlas.ts
new file mode 100644
index 00000000..7d6b87e9
--- /dev/null
+++ b/apps/web/src/hooks/use-font-atlas.ts
@@ -0,0 +1,53 @@
+import { useState, useMemo, useCallback, useEffect } from "react";
+import {
+ getCachedFontAtlas,
+ loadFontAtlas,
+ clearFontAtlasCache,
+} from "@/lib/fonts/google-fonts";
+import type { FontAtlas } from "@/lib/fonts/types";
+import { SYSTEM_FONTS } from "@/constants/font-constants";
+
+type Status = "idle" | "loading" | "error";
+
+export function useFontAtlas({ open }: { open: boolean }) {
+ const [atlas, setAtlas] = useState(() =>
+ getCachedFontAtlas(),
+ );
+ const [status, setStatus] = useState(() =>
+ getCachedFontAtlas() ? "idle" : "loading",
+ );
+
+ useEffect(() => {
+ if (!open || atlas) return;
+
+ setStatus("loading");
+ loadFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, [open, atlas]);
+
+ const retry = useCallback(() => {
+ clearFontAtlasCache();
+ setStatus("loading");
+ loadFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, []);
+
+ const fontNames = useMemo(() => {
+ if (!atlas) return [];
+ return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort();
+ }, [atlas]);
+
+ return { atlas, status, fontNames, retry };
+}
diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts
index e59d1eaf..69a4e847 100644
--- a/apps/web/src/hooks/use-keybindings.ts
+++ b/apps/web/src/hooks/use-keybindings.ts
@@ -1,6 +1,8 @@
import { useEffect } from "react";
import { invokeAction } from "@/lib/actions";
import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { isTypableDOMElement } from "@/utils/browser";
/**
* a composable that hooks to the caller component's
@@ -8,31 +10,141 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
* the appropriate actions based on keybindings
*/
export function useKeybindingsListener() {
- const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
- useKeybindingsStore();
+ const {
+ keybindings,
+ getKeybindingString,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ } = useKeybindingsStore();
+ const clipboard = useTimelineStore((state) => state.clipboard);
useEffect(() => {
const eventOptions: AddEventListenerOptions = { capture: true };
+ // #region agent log
+ fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H1",
+ location: "use-keybindings.ts:effect",
+ message: "Keybindings listener mounted",
+ data: {
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ keybindingCount: Object.keys(keybindings).length,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
const handleKeyDown = (ev: KeyboardEvent) => {
- // do not check keybinds if the mode is disabled
- if (!keybindingsEnabled) return;
- // ignore key events if user is changing keybindings
- if (isRecording) return;
+ const normalizedKey = (ev.key ?? "").toLowerCase();
+ const shouldLogKey =
+ ev.code === "Space" ||
+ ev.code.startsWith("Key") ||
+ ["escape", "delete", "backspace", "enter"].includes(normalizedKey);
+
+ if (overlayDepth > 0 || isLoadingProject || isRecording) {
+ if (shouldLogKey) {
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H2",
+ location: "use-keybindings.ts:blocked",
+ message: "Shortcut blocked by runtime gate",
+ data: {
+ key: ev.key,
+ code: ev.code,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ targetTag:
+ ev.target instanceof HTMLElement ? ev.target.tagName : null,
+ },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ }
+ return;
+ }
const binding = getKeybindingString(ev);
- if (!binding) return;
-
- const boundAction = keybindings[binding];
- if (!boundAction) return;
-
const activeElement = document.activeElement;
const isTextInput =
- activeElement &&
- (activeElement.tagName === "INPUT" ||
- activeElement.tagName === "TEXTAREA" ||
- (activeElement as HTMLElement).isContentEditable);
+ activeElement instanceof HTMLElement &&
+ isTypableDOMElement({ element: activeElement });
+ const boundAction = binding ? keybindings[binding] : undefined;
+
+ if (shouldLogKey || binding || boundAction) {
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: !binding ? "H3" : isTextInput ? "H5" : "H4",
+ location: "use-keybindings.ts:keydown",
+ message: "Shortcut keydown observed",
+ data: {
+ key: ev.key,
+ code: ev.code,
+ binding,
+ boundAction: boundAction ?? null,
+ isTextInput,
+ keybindingCount: Object.keys(keybindings).length,
+ activeTag:
+ activeElement instanceof HTMLElement
+ ? activeElement.tagName
+ : null,
+ targetTag:
+ ev.target instanceof HTMLElement ? ev.target.tagName : null,
+ },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ }
+
+ if (normalizedKey === "escape" && isTextInput) {
+ activeElement.blur();
+ return;
+ }
+
+ if (!binding) return;
+ if (!boundAction) return;
if (isTextInput) return;
+ if (boundAction === "paste-copied") {
+ if (!clipboard?.items.length) return;
+ ev.preventDefault();
+ invokeAction("paste-copied", undefined, "keypress");
+ return;
+ }
ev.preventDefault();
@@ -59,17 +171,12 @@ export function useKeybindingsListener() {
return () => {
document.removeEventListener("keydown", handleKeyDown, eventOptions);
};
- }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
-}
-
-/**
- * this composable allows for the UI component to be disabled if the component in question is mounted
- */
-export function useKeybindingDisabler() {
- const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
-
- return {
- disableKeybindings,
- enableKeybindings,
- };
+ }, [
+ keybindings,
+ getKeybindingString,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ clipboard,
+ ]);
}
diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts
new file mode 100644
index 00000000..c3e83304
--- /dev/null
+++ b/apps/web/src/hooks/use-mask-handles.ts
@@ -0,0 +1,258 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { masksRegistry } from "@/lib/masks";
+import {
+ getMaskHandlePositions,
+ getLineMaskLinePoints,
+} from "@/lib/masks/handle-positions";
+import { snapMaskInteraction } from "@/lib/masks/snap";
+import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
+import {
+ SNAP_THRESHOLD_SCREEN_PIXELS,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import type { ParamValues } from "@/lib/params";
+import type {
+ BaseMaskParams,
+ MaskHandlePosition,
+ MaskLinePoints,
+} from "@/lib/masks/types";
+import type { MaskableElement } from "@/lib/timeline";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+interface DragState {
+ trackId: string;
+ elementId: string;
+ handleId: string;
+ startCanvasX: number;
+ startCanvasY: number;
+ startParams: BaseMaskParams & ParamValues;
+}
+
+export function useMaskHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
+ const [activeHandleId, setActiveHandleId] = useState(null);
+ const dragStateRef = useRef(null);
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
+ );
+
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const selectedWithMask =
+ selectedElements.length === 1
+ ? (() => {
+ const sel = selectedElements[0];
+ const entry = elementsWithBounds.find(
+ (item) =>
+ item.trackId === sel.trackId && item.elementId === sel.elementId,
+ );
+ if (!entry) return null;
+ const element = entry.element as MaskableElement;
+ if (!element.masks?.length) return null;
+ return { ...entry, element, mask: element.masks[0] };
+ })()
+ : null;
+
+ const handlePositions: MaskHandlePosition[] = selectedWithMask
+ ? (() => {
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+ const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
+ const displayScale = (scaleX + scaleY) / 2;
+ return getMaskHandlePositions({
+ overlayShape: def.overlayShape,
+ features: def.features,
+ params: selectedWithMask.mask.params,
+ bounds: selectedWithMask.bounds,
+ displayScale,
+ });
+ })()
+ : [];
+
+ const linePoints: MaskLinePoints | null =
+ selectedWithMask?.mask.type === "split"
+ ? getLineMaskLinePoints({
+ centerX: selectedWithMask.mask.params.centerX,
+ centerY: selectedWithMask.mask.params.centerY,
+ rotation: selectedWithMask.mask.params.rotation,
+ bounds: selectedWithMask.bounds,
+ })
+ : null;
+
+ const clearMaskHandleState = useCallback(() => {
+ dragStateRef.current = null;
+ setActiveHandleId(null);
+ onSnapLinesChange?.([]);
+ }, [onSnapLinesChange]);
+
+ const releaseCapturedPointer = useCallback(() => {
+ const capture = captureRef.current;
+ if (!capture) return;
+
+ if (capture.element.hasPointerCapture(capture.pointerId)) {
+ capture.element.releasePointerCapture(capture.pointerId);
+ }
+
+ captureRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!activeHandleId) return;
+
+ return registerCanceller({
+ fn: () => {
+ editor.timeline.discardPreview();
+ clearMaskHandleState();
+ releaseCapturedPointer();
+ },
+ });
+ }, [
+ activeHandleId,
+ clearMaskHandleState,
+ editor.timeline,
+ releaseCapturedPointer,
+ ]);
+
+ const handlePointerDown = useCallback(
+ ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
+ if (!selectedWithMask) return;
+ event.stopPropagation();
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ dragStateRef.current = {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ handleId,
+ startCanvasX: pos.x,
+ startCanvasY: pos.y,
+ startParams: { ...selectedWithMask.mask.params },
+ };
+ setActiveHandleId(handleId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithMask, viewport],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const drag = dragStateRef.current;
+ if (!drag || !selectedWithMask) return;
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ const deltaX = pos.x - drag.startCanvasX;
+ const deltaY = pos.y - drag.startCanvasY;
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+
+ const rawParams = def.computeParamUpdate({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ deltaX,
+ deltaY,
+ startCanvasX: drag.startCanvasX,
+ startCanvasY: drag.startCanvasY,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ });
+ const proposedParams = { ...drag.startParams, ...rawParams };
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { params: nextParams, activeLines } = isShiftHeldRef.current
+ ? { params: proposedParams, activeLines: [] as SnapLine[] }
+ : snapMaskInteraction({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ proposedParams,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ onSnapLinesChange?.(activeLines);
+
+ const updatedMask = {
+ ...selectedWithMask.mask,
+ params: nextParams,
+ };
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: drag.trackId,
+ elementId: drag.elementId,
+ updates: {
+ masks: [
+ updatedMask,
+ ...(selectedWithMask.element.masks?.slice(1) ?? []),
+ ],
+ } as Partial,
+ },
+ ],
+ });
+ },
+ [
+ selectedWithMask,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ if (dragStateRef.current) {
+ editor.timeline.commitPreview();
+ clearMaskHandleState();
+ }
+ releaseCapturedPointer();
+ },
+ [clearMaskHandleState, editor, releaseCapturedPointer],
+ );
+
+ return {
+ selectedWithMask,
+ handlePositions,
+ linePoints,
+ activeHandleId,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ };
+}
diff --git a/apps/web/src/hooks/use-menu-preview.ts b/apps/web/src/hooks/use-menu-preview.ts
new file mode 100644
index 00000000..a17ee278
--- /dev/null
+++ b/apps/web/src/hooks/use-menu-preview.ts
@@ -0,0 +1,26 @@
+import { useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+
+export function useMenuPreview() {
+ const editor = useEditor();
+ const didCommitRef = useRef(false);
+
+ const discard = () => {
+ if (!didCommitRef.current && editor.timeline.isPreviewActive()) {
+ editor.timeline.discardPreview();
+ }
+ };
+
+ const markCommitted = () => {
+ didCommitRef.current = true;
+ };
+
+ const onOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ discard();
+ didCommitRef.current = false;
+ }
+ };
+
+ return { onPointerLeave: discard, onOpenChange, markCommitted };
+}
diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts
index 4533ab28..8f362a12 100644
--- a/apps/web/src/hooks/use-paste-media.ts
+++ b/apps/web/src/hooks/use-paste-media.ts
@@ -1,14 +1,15 @@
import { useEffect } from "react";
-import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import { invokeAction } from "@/lib/actions";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { isTypableDOMElement } from "@/utils/browser";
-import type { MediaType } from "@/types/assets";
+import type { MediaType } from "@/lib/media/types";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
@@ -40,6 +41,7 @@ export function usePasteMedia() {
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
+
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
@@ -47,7 +49,11 @@ export function usePasteMedia() {
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
- if (files.length === 0) return;
+ if (files.length === 0) {
+ event.preventDefault();
+ invokeAction("paste-copied");
+ return;
+ }
event.preventDefault();
@@ -55,41 +61,50 @@ export function usePasteMedia() {
if (!activeProject) return;
try {
- const processedAssets = await processMediaAssets({ files });
- const startTime = editor.playback.getCurrentTime();
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const startTime = editor.playback.getCurrentTime();
- for (const asset of processedAssets) {
- const addMediaCmd = new AddMediaAssetCommand(
- activeProject.metadata.id,
- asset,
- );
- const assetId = addMediaCmd.getAssetId();
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const trackType = asset.type === "audio" ? "audio" : "video";
+ for (const asset of processedAssets) {
+ const addMediaCmd = new AddMediaAssetCommand(
+ activeProject.metadata.id,
+ asset,
+ );
+ const assetId = addMediaCmd.getAssetId();
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const trackType = asset.type === "audio" ? "audio" : "video";
- const element = buildElementFromMedia({
- mediaId: assetId,
- mediaType: asset.type,
- name: asset.name,
- duration,
- startTime,
- buffer:
- asset.type === "audio"
- ? new AudioBuffer({ length: 1, sampleRate: 44100 })
- : undefined,
- });
+ const element = buildElementFromMedia({
+ mediaId: assetId,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ buffer:
+ asset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "auto", trackType },
- });
- const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
- editor.command.execute({ command: batchCmd });
- }
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "auto", trackType },
+ });
+ const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
+ editor.command.execute({ command: batchCmd });
+ }
+
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
} catch (error) {
console.error("Failed to paste media:", error);
- toast.error("Failed to paste media");
}
};
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
index d29f2a4b..90fa0450 100644
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -1,28 +1,50 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import type { TextElement, Transform } from "@/types/timeline";
-import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
-import { hitTest } from "@/lib/preview/hit-test";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { Transform } from "@/lib/rendering";
+import type { ElementRef, TextElement } from "@/lib/timeline";
import {
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
+ getVisibleElementsWithBounds,
+ type ElementWithBounds,
+} from "@/lib/preview/element-bounds";
+import {
+ getHitElements,
+ hitTest,
+ resolvePreferredHit,
+} from "@/lib/preview/hit-test";
import { isVisualElement } from "@/lib/timeline/element-utils";
import {
SNAP_THRESHOLD_SCREEN_PIXELS,
snapPosition,
type SnapLine,
} from "@/lib/preview/preview-snap";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+export type OnSnapLinesChange = (lines: SnapLine[]) => void;
const MIN_DRAG_DISTANCE = 0.5;
-interface DragState {
+interface CapturedPointerState {
+ pointerId: number;
+ captureTarget: HTMLElement;
+}
+
+interface PendingGestureState extends CapturedPointerState {
+ startX: number;
+ startY: number;
+ topmostHit: ElementWithBounds | null;
+ selectedHit: ElementWithBounds | null;
+ selectedElements: ElementRef[];
+}
+
+interface DragState extends CapturedPointerState {
startX: number;
startY: number;
bounds: {
width: number;
height: number;
+ rotation: number;
};
elements: Array<{
trackId: string;
@@ -31,15 +53,56 @@ interface DragState {
}>;
}
-export function usePreviewInteraction({
- canvasRef,
+function isSameElementRef({
+ left,
+ right,
}: {
- canvasRef: React.RefObject;
+ left: ElementRef;
+ right: ElementRef;
+}): boolean {
+ return left.trackId === right.trackId && left.elementId === right.elementId;
+}
+
+function buildDragSelection({
+ selectedElements,
+ dragTarget,
+}: {
+ selectedElements: ElementRef[];
+ dragTarget: ElementWithBounds;
+}): ElementRef[] {
+ const dragTargetRef = {
+ trackId: dragTarget.trackId,
+ elementId: dragTarget.elementId,
+ };
+
+ if (
+ !selectedElements.some((selectedElement) =>
+ isSameElementRef({ left: selectedElement, right: dragTargetRef }),
+ )
+ ) {
+ return [dragTargetRef];
+ }
+
+ return [
+ dragTargetRef,
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !isSameElementRef({ left: selectedElement, right: dragTargetRef }),
+ ),
+ ];
+}
+
+export function usePreviewInteraction({
+ onSnapLinesChange,
+ isMaskMode = false,
+}: {
+ onSnapLinesChange?: OnSnapLinesChange;
+ isMaskMode?: boolean;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
const [isDragging, setIsDragging] = useState(false);
- const [snapLines, setSnapLines] = useState([]);
const [editingText, setEditingText] = useState<{
trackId: string;
elementId: string;
@@ -47,13 +110,30 @@ export function usePreviewInteraction({
originalOpacity: number;
} | null>(null);
const dragStateRef = useRef(null);
+ const pendingGestureRef = useRef(null);
const wasPlayingRef = useRef(editor.playback.getIsPlaying());
const editingTextRef = useRef(editingText);
editingTextRef.current = editingText;
+ const releaseCapturedPointer = useCallback(
+ (pointerState: CapturedPointerState | null) => {
+ if (!pointerState) return;
+
+ if (
+ !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)
+ ) {
+ return;
+ }
+
+ pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
+ },
+ [],
+ );
+
const commitTextEdit = useCallback(() => {
const current = editingTextRef.current;
if (!current) return;
+ editingTextRef.current = null;
editor.timeline.previewElements({
updates: [
{
@@ -67,11 +147,6 @@ export function usePreviewInteraction({
setEditingText(null);
}, [editor.timeline]);
- const cancelTextEdit = useCallback(() => {
- editor.timeline.discardPreview();
- setEditingText(null);
- }, [editor.timeline]);
-
useEffect(() => {
const unsubscribe = editor.playback.subscribe(() => {
const isPlaying = editor.playback.getIsPlaying();
@@ -83,20 +158,38 @@ export function usePreviewInteraction({
return unsubscribe;
}, [editor.playback, commitTextEdit]);
+ useEffect(() => {
+ if (!isDragging) return;
+
+ return registerCanceller({
+ fn: () => {
+ const dragState = dragStateRef.current;
+ if (!dragState) return;
+
+ editor.timeline.discardPreview();
+ dragStateRef.current = null;
+ pendingGestureRef.current = null;
+ setIsDragging(false);
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(dragState);
+ },
+ });
+ }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
+
const handleDoubleClick = useCallback(
({ clientX, clientY }: React.MouseEvent) => {
- if (!canvasRef.current || editingText) return;
+ if (editingText || isMaskMode) return;
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
- const startPos = screenToCanvas({
+ const startPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -130,7 +223,7 @@ export function usePreviewInteraction({
originalOpacity: textElement.opacity,
});
},
- [canvasRef, editor, editingText],
+ [editor, editingText, isMaskMode, viewport],
);
const handlePointerDown = useCallback(
@@ -141,8 +234,8 @@ export function usePreviewInteraction({
pointerId,
button,
}: React.PointerEvent) => {
- if (!canvasRef.current) return;
if (editingText) return;
+ if (isMaskMode) return;
if (button !== 0) return;
const tracks = editor.timeline.getTracks();
@@ -150,11 +243,11 @@ export function usePreviewInteraction({
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
- const startPos = screenToCanvas({
+ const startPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -163,89 +256,134 @@ export function usePreviewInteraction({
mediaAssets,
});
- const hit = hitTest({
+ const hits = getHitElements({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds,
});
+ const selectedElements = editor.selection.getSelectedElements();
+ const topmostHit = hits[0] ?? null;
- if (!hit) {
- editor.selection.clearSelection();
- return;
- }
-
- editor.selection.setSelectedElements({
- elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
- });
-
- const elementsWithTracks = editor.timeline.getElementsWithTracks({
- elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
- });
-
- const draggableElements = elementsWithTracks.filter(({ element }) =>
- isVisualElement(element),
- );
-
- if (draggableElements.length === 0) return;
-
- dragStateRef.current = {
+ pendingGestureRef.current = {
startX: startPos.x,
startY: startPos.y,
- bounds: {
- width: hit.bounds.width,
- height: hit.bounds.height,
- },
- elements: draggableElements.map(({ track, element }) => ({
- trackId: track.id,
- elementId: element.id,
- initialTransform: (element as { transform: Transform }).transform,
- })),
+ pointerId,
+ captureTarget: currentTarget as HTMLElement,
+ topmostHit,
+ selectedHit: resolvePreferredHit({
+ hits,
+ preferredElements: selectedElements,
+ }),
+ selectedElements,
};
-
- setIsDragging(true);
currentTarget.setPointerCapture(pointerId);
},
- [editor, canvasRef, editingText],
+ [editor, editingText, isMaskMode, viewport],
);
const handlePointerMove = useCallback(
({ clientX, clientY }: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
-
const canvasSize = editor.project.getActive().settings.canvasSize;
- const currentPos = screenToCanvas({
+ const currentPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!currentPos) return;
- const deltaX = currentPos.x - dragStateRef.current.startX;
- const deltaY = currentPos.y - dragStateRef.current.startY;
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
- if (!hasMovement) {
- setSnapLines([]);
- return;
+ let dragState = dragStateRef.current;
+
+ if (!dragState) {
+ const pendingGesture = pendingGestureRef.current;
+ if (!pendingGesture) return;
+
+ const deltaX = currentPos.x - pendingGesture.startX;
+ const deltaY = currentPos.y - pendingGesture.startY;
+ const hasMovement =
+ Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
+ Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+
+ if (!hasMovement) {
+ onSnapLinesChange?.([]);
+ return;
+ }
+
+ const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
+ if (!dragTarget) {
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
+ return;
+ }
+
+ const dragSelection = buildDragSelection({
+ selectedElements: pendingGesture.selectedElements,
+ dragTarget,
+ });
+ const elementsWithTracks = editor.timeline.getElementsWithTracks({
+ elements: dragSelection,
+ });
+ const draggableElements = elementsWithTracks.filter(({ element }) =>
+ isVisualElement(element),
+ );
+
+ if (draggableElements.length === 0) {
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
+ return;
+ }
+
+ if (pendingGesture.selectedHit === null) {
+ editor.selection.setSelectedElements({
+ elements: [
+ {
+ trackId: dragTarget.trackId,
+ elementId: dragTarget.elementId,
+ },
+ ],
+ });
}
- const firstElement = dragStateRef.current.elements[0];
+ dragState = {
+ startX: pendingGesture.startX,
+ startY: pendingGesture.startY,
+ pointerId: pendingGesture.pointerId,
+ captureTarget: pendingGesture.captureTarget,
+ bounds: {
+ width: dragTarget.bounds.width,
+ height: dragTarget.bounds.height,
+ rotation: dragTarget.bounds.rotation,
+ },
+ elements: draggableElements.map(({ track, element }) => ({
+ trackId: track.id,
+ elementId: element.id,
+ initialTransform: (element as { transform: Transform }).transform,
+ })),
+ };
+ dragStateRef.current = dragState;
+ pendingGestureRef.current = null;
+ setIsDragging(true);
+ }
+
+ const deltaX = currentPos.x - dragState.startX;
+ const deltaY = currentPos.y - dragState.startY;
+ const firstElement = dragState.elements[0];
const proposedPosition = {
x: firstElement.initialTransform.position.x + deltaX,
y: firstElement.initialTransform.position.y + deltaY,
};
const shouldSnap = !isShiftHeldRef.current;
- const snapThreshold = screenPixelsToLogicalThreshold({
- canvas: canvasRef.current,
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const { snappedPosition, activeLines } = shouldSnap
? snapPosition({
proposedPosition,
canvasSize,
- elementSize: dragStateRef.current.bounds,
+ elementSize: dragState.bounds,
+ rotation: dragState.bounds.rotation,
snapThreshold,
})
: {
@@ -253,14 +391,14 @@ export function usePreviewInteraction({
activeLines: [] as SnapLine[],
};
- setSnapLines(activeLines);
+ onSnapLinesChange?.(activeLines);
const deltaSnappedX =
snappedPosition.x - firstElement.initialTransform.position.x;
const deltaSnappedY =
snappedPosition.y - firstElement.initialTransform.position.y;
- const updates = dragStateRef.current.elements.map(
+ const updates = dragState.elements.map(
({ trackId, elementId, initialTransform }) => ({
trackId,
elementId,
@@ -278,38 +416,51 @@ export function usePreviewInteraction({
editor.timeline.previewElements({ updates });
},
- [isDragging, canvasRef, editor, isShiftHeldRef],
+ [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
);
const handlePointerUp = useCallback(
- ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
+ ({ type }: React.PointerEvent) => {
+ const dragState = dragStateRef.current;
+ if (dragState) {
+ if (type === "pointercancel") {
+ editor.timeline.discardPreview();
+ } else {
+ editor.timeline.commitPreview();
+ }
- const currentPos = screenToCanvas({
- clientX,
- clientY,
- canvas: canvasRef.current,
- });
-
- const deltaX = currentPos.x - dragStateRef.current.startX;
- const deltaY = currentPos.y - dragStateRef.current.startY;
-
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
-
- if (!hasMovement) {
- editor.timeline.discardPreview();
- } else {
- editor.timeline.commitPreview();
+ dragStateRef.current = null;
+ pendingGestureRef.current = null;
+ setIsDragging(false);
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(dragState);
+ return;
}
- dragStateRef.current = null;
- setIsDragging(false);
- setSnapLines([]);
- currentTarget.releasePointerCapture(pointerId);
+ const pendingGesture = pendingGestureRef.current;
+ if (!pendingGesture) return;
+
+ if (type !== "pointercancel") {
+ const clickTarget = pendingGesture.topmostHit;
+ if (!clickTarget) {
+ editor.selection.clearSelection();
+ } else {
+ editor.selection.setSelectedElements({
+ elements: [
+ {
+ trackId: clickTarget.trackId,
+ elementId: clickTarget.elementId,
+ },
+ ],
+ });
+ }
+ }
+
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
},
- [isDragging, canvasRef, editor],
+ [editor, onSnapLinesChange, releaseCapturedPointer],
);
return {
@@ -317,9 +468,7 @@ export function usePreviewInteraction({
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onDoubleClick: handleDoubleClick,
- snapLines,
editingText,
commitTextEdit,
- cancelTextEdit,
};
}
diff --git a/apps/web/src/hooks/use-resize-observer.ts b/apps/web/src/hooks/use-resize-observer.ts
new file mode 100644
index 00000000..a4b3a603
--- /dev/null
+++ b/apps/web/src/hooks/use-resize-observer.ts
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+
+export function useResizeObserver({
+ ref,
+ onResize,
+}: {
+ ref: React.RefObject;
+ onResize: (entry: ResizeObserverEntry) => void;
+}) {
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) return;
+
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ onResize(entry);
+ }
+ });
+
+ observer.observe(element);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [ref, onResize]);
+}
diff --git a/apps/web/src/hooks/use-storage-persistence.ts b/apps/web/src/hooks/use-storage-persistence.ts
new file mode 100644
index 00000000..79e05069
--- /dev/null
+++ b/apps/web/src/hooks/use-storage-persistence.ts
@@ -0,0 +1,45 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const DISMISSED_KEY = "opencut-storage-persist-dismissed";
+
+function isFirefox(): boolean {
+ return navigator.userAgent.toLowerCase().includes("firefox");
+}
+
+export function useStoragePersistence() {
+ const [showDialog, setShowDialog] = useState(false);
+
+ useEffect(() => {
+ if (!navigator.storage?.persist) return;
+
+ const run = async () => {
+ const alreadyPersisted = await navigator.storage.persisted();
+ if (alreadyPersisted) return;
+
+ const dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
+ if (dismissed) return;
+
+ if (isFirefox()) {
+ setShowDialog(true);
+ } else {
+ await navigator.storage.persist();
+ }
+ };
+
+ run();
+ }, []);
+
+ const onConfirm = async () => {
+ setShowDialog(false);
+ await navigator.storage.persist();
+ };
+
+ const onDismiss = () => {
+ setShowDialog(false);
+ localStorage.setItem(DISMISSED_KEY, "true");
+ };
+
+ return { showDialog, onConfirm, onDismiss };
+}
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
index fb561396..a01fb361 100644
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -1,19 +1,19 @@
-import { useCallback, useRef, useState, useSyncExternalStore } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
getVisibleElementsWithBounds,
type ElementWithBounds,
} from "@/lib/preview/element-bounds";
-import {
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
import {
MIN_SCALE,
SNAP_THRESHOLD_SCREEN_PIXELS,
snapRotation,
snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
type SnapLine,
} from "@/lib/preview/preview-snap";
import { isVisualElement } from "@/lib/timeline/element-utils";
@@ -22,11 +22,25 @@ import {
resolveTransformAtTime,
setChannel,
} from "@/lib/animation";
-import type { Transform } from "@/types/timeline";
-import type { ElementAnimations } from "@/types/animation";
+import type { Transform } from "@/lib/rendering";
+import type { ElementAnimations } from "@/lib/animation/types";
+import { registerCanceller } from "@/lib/cancel-interaction";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-type HandleType = Corner | "rotation";
+type Edge = "right" | "left" | "bottom";
+type HandleType = Corner | Edge | "rotation";
+
+function getPreferredEdge({
+ edge,
+}: {
+ edge: Edge;
+}): ScaleEdgePreference {
+ return edge === "right"
+ ? { right: true }
+ : edge === "left"
+ ? { left: true }
+ : { bottom: true };
+}
interface ScaleState {
trackId: string;
@@ -50,26 +64,25 @@ interface RotationState {
initialBoundsCy: number;
}
-function areSnapLinesEqual({
- previousLines,
- nextLines,
-}: {
- previousLines: SnapLine[];
- nextLines: SnapLine[];
-}): boolean {
- if (previousLines.length !== nextLines.length) {
- return false;
+interface EdgeScaleState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+ baseWidth: number;
+ baseHeight: number;
+ edge: Edge;
+ rotationRad: number;
+ shouldClearScaleAnimation: boolean;
+ animationsWithoutScale: ElementAnimations | undefined;
+}
+
+function clampScaleNonZero(scale: number): number {
+ if (Math.abs(scale) < MIN_SCALE) {
+ return scale < 0 ? -MIN_SCALE : MIN_SCALE;
}
- for (const [index, line] of previousLines.entries()) {
- const nextLine = nextLines[index];
- if (!nextLine) {
- return false;
- }
- if (line.type !== nextLine.type || line.position !== nextLine.position) {
- return false;
- }
- }
- return true;
+ return scale;
}
function getCornerDistance({
@@ -102,29 +115,30 @@ function getCornerDistance({
}
export function useTransformHandles({
- canvasRef,
+ onSnapLinesChange,
}: {
- canvasRef: React.RefObject;
+ onSnapLinesChange?: OnSnapLinesChange;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
const [activeHandle, setActiveHandle] = useState(null);
- const [snapLines, setSnapLines] = useState([]);
- const snapLinesRef = useRef([]);
const scaleStateRef = useRef(null);
const rotationStateRef = useRef(null);
-
- const selectedElements = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedElements(),
+ const edgeScaleStateRef = useRef(null);
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
);
- const tracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
- const mediaAssets = editor.media.getAssets();
- const canvasSize = editor.project.getActive().settings.canvasSize;
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -145,6 +159,37 @@ export function useTransformHandles({
const hasVisualSelection =
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
+ const clearActiveHandleState = useCallback(() => {
+ scaleStateRef.current = null;
+ rotationStateRef.current = null;
+ edgeScaleStateRef.current = null;
+ setActiveHandle(null);
+ onSnapLinesChange?.([]);
+ }, [onSnapLinesChange]);
+
+ const releaseCapturedPointer = useCallback(() => {
+ const capture = captureRef.current;
+ if (!capture) return;
+
+ if (capture.element.hasPointerCapture(capture.pointerId)) {
+ capture.element.releasePointerCapture(capture.pointerId);
+ }
+
+ captureRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!activeHandle) return;
+
+ return registerCanceller({
+ fn: () => {
+ editor.timeline.discardPreview();
+ clearActiveHandleState();
+ releaseCapturedPointer();
+ },
+ });
+ }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]);
+
const handleCornerPointerDown = useCallback(
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
if (!selectedWithBounds) return;
@@ -165,14 +210,19 @@ export function useTransformHandles({
});
const initialDistance = getCornerDistance({ bounds, corner });
- const baseWidth = bounds.width / resolvedTransform.scale;
- const baseHeight = bounds.height / resolvedTransform.scale;
+ const baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
const shouldClearScaleAnimation =
- !!element.animations?.channels["transform.scale"];
+ !!element.animations?.channels["transform.scaleX"] ||
+ !!element.animations?.channels["transform.scaleY"];
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
- animations: element.animations,
- propertyPath: "transform.scale",
+ animations: setChannel({
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ channel: undefined,
+ }),
+ propertyPath: "transform.scaleY",
channel: undefined,
})
: element.animations;
@@ -190,14 +240,19 @@ export function useTransformHandles({
animationsWithoutScale,
};
setActiveHandle(corner);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
},
[selectedWithBounds],
);
const handleRotationPointerDown = useCallback(
({ event }: { event: React.PointerEvent }) => {
- if (!selectedWithBounds || !canvasRef.current) return;
+ if (!selectedWithBounds) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
@@ -214,14 +269,14 @@ export function useTransformHandles({
localTime,
});
- const position = screenToCanvas({
+ const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
- canvas: canvasRef.current,
});
- const deltaX = position.x - bounds.cx;
- const deltaY = position.y - bounds.cy;
- const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ if (!position) return;
+ const deltaX = position.x - bounds.cx;
+ const deltaY = position.y - bounds.cy;
+ const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
rotationStateRef.current = {
trackId,
@@ -232,21 +287,91 @@ export function useTransformHandles({
initialBoundsCy: bounds.cy,
};
setActiveHandle("rotation");
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
},
- [selectedWithBounds, canvasRef],
+ [selectedWithBounds, viewport],
+ );
+
+ const handleEdgePointerDown = useCallback(
+ ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
+ const rotationRad = (bounds.rotation * Math.PI) / 180;
+
+ const propertyPath =
+ edge === "right" || edge === "left"
+ ? "transform.scaleX"
+ : "transform.scaleY";
+ const shouldClearScaleAnimation =
+ !!element.animations?.channels[propertyPath];
+ const animationsWithoutScale = shouldClearScaleAnimation
+ ? setChannel({
+ animations: element.animations,
+ propertyPath,
+ channel: undefined,
+ })
+ : element.animations;
+
+ edgeScaleStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: resolvedTransform,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ };
+ setActiveHandle(edge);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithBounds],
);
const handlePointerMove = useCallback(
({ event }: { event: React.PointerEvent }) => {
- if (!canvasRef.current) return;
- if (!scaleStateRef.current && !rotationStateRef.current) return;
+ if (
+ !scaleStateRef.current &&
+ !rotationStateRef.current &&
+ !edgeScaleStateRef.current
+ )
+ return;
- const position = screenToCanvas({
+ const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
- canvas: canvasRef.current,
});
+ if (!position) return;
if (
scaleStateRef.current &&
@@ -266,58 +391,157 @@ export function useTransformHandles({
animationsWithoutScale,
} = scaleStateRef.current;
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentDistance =
+ Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
const scaleFactor = currentDistance / initialDistance;
- const proposedScale = Math.max(
- MIN_SCALE,
- initialTransform.scale * scaleFactor,
- );
- const canvasSize = editor.project.getActive().settings.canvasSize;
- const snapThreshold = screenPixelsToLogicalThreshold({
- canvas: canvasRef.current,
+ // Use actual element dimensions (base * current scale) so snap
+ // computes the correct edges when scaleX ≠ scaleY
+ const effectiveWidth = baseWidth * initialTransform.scaleX;
+ const effectiveHeight = baseHeight * initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
- const shouldSnap = !isShiftHeldRef.current;
- const { snappedScale, activeLines } = shouldSnap
- ? snapScale({
- proposedScale,
- position: initialTransform.position,
- baseWidth,
- baseHeight,
- canvasSize,
- snapThreshold,
- })
- : { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
+ const { snappedScale: snappedFactor, activeLines } =
+ isShiftHeldRef.current
+ ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
+ : snapScale({
+ proposedScale: scaleFactor,
+ position: initialTransform.position,
+ baseWidth: effectiveWidth,
+ baseHeight: effectiveHeight,
+ rotation: initialTransform.rotate,
+ canvasSize,
+ snapThreshold,
+ });
- const isSameLines = areSnapLinesEqual({
- previousLines: snapLinesRef.current,
- nextLines: activeLines,
- });
-
- if (!isSameLines) {
- snapLinesRef.current = activeLines;
- setSnapLines(activeLines);
- }
-
- const updates: {
- transform: Transform;
- animations?: ElementAnimations;
- } = {
- transform: { ...initialTransform, scale: snappedScale },
- };
- if (shouldClearScaleAnimation) {
- updates.animations = animationsWithoutScale;
- }
+ onSnapLinesChange?.(activeLines);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
- updates,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX: clampScaleNonZero(
+ initialTransform.scaleX * snappedFactor,
+ ),
+ scaleY: clampScaleNonZero(
+ initialTransform.scaleY * snappedFactor,
+ ),
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (
+ edgeScaleStateRef.current &&
+ (activeHandle === "right" ||
+ activeHandle === "left" ||
+ activeHandle === "bottom")
+ ) {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialBoundsCx,
+ initialBoundsCy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ } = edgeScaleStateRef.current;
+
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const xProjection =
+ deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
+ const yProjection =
+ -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
+ const projection =
+ edge === "right"
+ ? xProjection
+ : edge === "left"
+ ? -xProjection
+ : yProjection;
+
+ const baseAxisHalf =
+ edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
+ const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
+
+ const proposedScaleX =
+ edge === "right" || edge === "left"
+ ? proposedScale
+ : initialTransform.scaleX;
+ const proposedScaleY =
+ edge === "bottom" ? proposedScale : initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { x: xSnap, y: ySnap } = isShiftHeldRef.current
+ ? {
+ x: {
+ snappedScale: proposedScaleX,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ y: {
+ snappedScale: proposedScaleY,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ }
+ : snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY,
+ position: initialTransform.position,
+ baseWidth,
+ baseHeight,
+ rotation: initialTransform.rotate,
+ canvasSize,
+ snapThreshold,
+ preferredEdges: getPreferredEdge({ edge }),
+ });
+
+ const relevantSnap =
+ edge === "right" || edge === "left" ? xSnap : ySnap;
+ onSnapLinesChange?.(relevantSnap.activeLines);
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX:
+ edge === "right" || edge === "left"
+ ? xSnap.snappedScale
+ : initialTransform.scaleX,
+ scaleY:
+ edge === "bottom"
+ ? ySnap.snappedScale
+ : initialTransform.scaleY,
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
},
],
});
@@ -334,17 +558,16 @@ export function useTransformHandles({
initialBoundsCy,
} = rotationStateRef.current;
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
let deltaAngle = currentAngle - initialAngle;
if (deltaAngle > 180) deltaAngle -= 360;
if (deltaAngle < -180) deltaAngle += 360;
const newRotate = initialTransform.rotate + deltaAngle;
- const shouldSnapRotation = !isShiftHeldRef.current;
- const { snappedRotation } = shouldSnapRotation
- ? snapRotation({ proposedRotation: newRotate })
- : { snappedRotation: newRotate };
+ const { snappedRotation } = isShiftHeldRef.current
+ ? { snappedRotation: newRotate }
+ : snapRotation({ proposedRotation: newRotate });
editor.timeline.previewElements({
updates: [
@@ -359,32 +582,36 @@ export function useTransformHandles({
});
}
},
- [activeHandle, canvasRef, editor, isShiftHeldRef],
+ [
+ activeHandle,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
);
- const handlePointerUp = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (scaleStateRef.current || rotationStateRef.current) {
+ const handlePointerUp = useCallback(() => {
+ if (
+ scaleStateRef.current ||
+ rotationStateRef.current ||
+ edgeScaleStateRef.current
+ ) {
editor.timeline.commitPreview();
- scaleStateRef.current = null;
- rotationStateRef.current = null;
- setActiveHandle(null);
- snapLinesRef.current = [];
- setSnapLines([]);
+ clearActiveHandleState();
}
- (event.currentTarget as HTMLElement).releasePointerCapture(
- event.pointerId,
- );
+ releaseCapturedPointer();
},
- [editor],
+ [clearActiveHandleState, editor, releaseCapturedPointer],
);
return {
selectedWithBounds,
hasVisualSelection,
activeHandle,
- snapLines,
handleCornerPointerDown,
+ handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts
index 82319216..4d67c1ef 100644
--- a/apps/web/src/lib/actions/definitions.ts
+++ b/apps/web/src/lib/actions/definitions.ts
@@ -1,4 +1,4 @@
-import type { ShortcutKey } from "@/types/keybinding";
+import type { ShortcutKey } from "@/lib/actions/keybinding";
export type TActionCategory =
| "playback"
@@ -7,7 +7,8 @@ export type TActionCategory =
| "selection"
| "history"
| "timeline"
- | "controls";
+ | "controls"
+ | "assets";
export interface TActionDefinition {
description: string;
@@ -114,10 +115,14 @@ export const ACTIONS = {
category: "selection",
defaultShortcuts: ["ctrl+a"],
},
+ "cancel-interaction": {
+ description: "Cancel current interaction",
+ category: "controls",
+ defaultShortcuts: ["escape"],
+ },
"deselect-all": {
description: "Deselect all elements",
category: "selection",
- defaultShortcuts: ["escape"],
},
"duplicate-selected": {
description: "Duplicate selected element",
@@ -146,11 +151,25 @@ export const ACTIONS = {
category: "history",
defaultShortcuts: ["ctrl+shift+z", "ctrl+y"],
},
+ "remove-media-asset": {
+ description: "Remove media asset",
+ category: "assets",
+ args: { projectId: "string", assetId: "string" },
+ },
+ "remove-media-assets": {
+ description: "Remove media assets",
+ category: "assets",
+ args: { projectId: "string", assetIds: "string[]" },
+ },
} as const satisfies Record;
export type TAction = keyof typeof ACTIONS;
-export function getActionDefinition({ action }: { action: TAction }): TActionDefinition {
+export function getActionDefinition({
+ action,
+}: {
+ action: TAction;
+}): TActionDefinition {
return ACTIONS[action];
}
diff --git a/apps/web/src/types/keybinding.ts b/apps/web/src/lib/actions/keybinding.ts
similarity index 94%
rename from apps/web/src/types/keybinding.ts
rename to apps/web/src/lib/actions/keybinding.ts
index f9b84e31..0a61893b 100644
--- a/apps/web/src/types/keybinding.ts
+++ b/apps/web/src/lib/actions/keybinding.ts
@@ -1,4 +1,4 @@
-import type { TActionWithOptionalArgs } from "@/lib/actions";
+import type { TActionWithOptionalArgs } from "./types";
/**
* Alt is also regarded as macOS OPTION (⌥) key
diff --git a/apps/web/src/lib/actions/registry.ts b/apps/web/src/lib/actions/registry.ts
index b9c84529..c825a5e4 100644
--- a/apps/web/src/lib/actions/registry.ts
+++ b/apps/web/src/lib/actions/registry.ts
@@ -57,5 +57,30 @@ export const invokeAction: InvokeActionFunc = (
args?: TArgOfAction ,
trigger?: TInvocationTrigger,
) => {
- boundActions[action]?.forEach((handler) => handler(args, trigger));
+ if (trigger === "keypress") {
+ // #region agent log
+ fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H4",
+ location: "actions/registry.ts:invokeAction",
+ message: "Action invoked from keypress",
+ data: {
+ action,
+ handlerCount: boundActions[action]?.length ?? 0,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
+ }
+ boundActions[action]?.forEach((handler) => {
+ handler(args, trigger);
+ });
};
diff --git a/apps/web/src/lib/actions/types.ts b/apps/web/src/lib/actions/types.ts
index 508bf10e..38627b95 100644
--- a/apps/web/src/lib/actions/types.ts
+++ b/apps/web/src/lib/actions/types.ts
@@ -8,6 +8,8 @@ export type TActionArgsMap = {
"seek-backward": { seconds: number } | undefined;
"jump-forward": { seconds: number } | undefined;
"jump-backward": { seconds: number } | undefined;
+ "remove-media-asset": { projectId: string; assetId: string };
+ "remove-media-assets": { projectId: string; assetIds: string[] };
};
type TKeysWithValueUndefined = {
diff --git a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
deleted file mode 100644
index 629bd0d6..00000000
--- a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
+++ /dev/null
@@ -1,313 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type { ElementAnimations } from "@/types/animation";
-import {
- clampAnimationsToDuration,
- getElementKeyframes,
- getKeyframeAtTime,
- hasKeyframesForPath,
- getChannelValueAtTime,
- getElementLocalTime,
- resolveTransformAtTime,
- splitAnimationsAtTime,
-} from "@/lib/animation";
-
-describe("transform keyframe evaluation", () => {
- test("uses fallback value when channel is missing", () => {
- const value = getChannelValueAtTime({
- channel: undefined,
- time: 1,
- fallbackValue: 42,
- });
- expect(value).toBe(42);
- });
-
- test("returns boundary value when time is within epsilon of first/last keyframe", () => {
- const channel = {
- valueKind: "number" as const,
- keyframes: [
- { id: "a", time: 0, value: 10, interpolation: "linear" as const },
- { id: "b", time: 2, value: 30, interpolation: "linear" as const },
- ],
- };
- expect(
- getChannelValueAtTime({
- channel,
- time: 0.0008,
- fallbackValue: 0,
- }),
- ).toBe(10);
- expect(
- getChannelValueAtTime({
- channel,
- time: 1.9992,
- fallbackValue: 0,
- }),
- ).toBe(30);
- });
-
- test("interpolates linear channels", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 10, interpolation: "linear" },
- { id: "b", time: 2, value: 30, interpolation: "linear" },
- ],
- },
- time: 1,
- fallbackValue: 0,
- });
- expect(value).toBe(20);
- });
-
- test("clamps local time to [0, duration]", () => {
- expect(
- getElementLocalTime({
- timelineTime: 2,
- elementStartTime: 5,
- elementDuration: 4,
- }),
- ).toBe(0);
- expect(
- getElementLocalTime({
- timelineTime: 12,
- elementStartTime: 5,
- elementDuration: 4,
- }),
- ).toBe(4);
- expect(
- getElementLocalTime({
- timelineTime: 7,
- elementStartTime: 5,
- elementDuration: 4,
- }),
- ).toBe(2);
- });
-
- test("uses hold interpolation from the left keyframe", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 10, interpolation: "hold" },
- { id: "b", time: 2, value: 30, interpolation: "linear" },
- ],
- },
- time: 1,
- fallbackValue: 0,
- });
- expect(value).toBe(10);
- });
-
- test("resolves transform by mixing animated and fallback properties", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.position.x": {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 0, interpolation: "linear" },
- { id: "b", time: 4, value: 80, interpolation: "linear" },
- ],
- },
- "transform.scale": {
- valueKind: "number",
- keyframes: [{ id: "c", time: 0, value: 2, interpolation: "hold" }],
- },
- },
- };
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: {
- position: { x: 10, y: 20 },
- scale: 1,
- rotate: 15,
- },
- animations,
- localTime: 2,
- });
- expect(resolvedTransform).toEqual({
- position: { x: 40, y: 20 },
- scale: 2,
- rotate: 15,
- });
- });
-});
-
-describe("transform keyframe mutation utilities", () => {
- test("splits channels and rebases right side times", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.scale": {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 1, interpolation: "linear" },
- { id: "b", time: 2, value: 2, interpolation: "linear" },
- { id: "c", time: 6, value: 4, interpolation: "linear" },
- ],
- },
- },
- };
- const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
- animations,
- splitTime: 4,
- });
-
- expect(
- leftAnimations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2, 4]);
- expect(
- rightAnimations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2]);
- expect(
- rightAnimations?.channels["transform.scale"]?.keyframes[0]?.value,
- ).toBe(3);
- });
-
- test("clamps channels to updated element duration", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.rotate": {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 0, interpolation: "linear" },
- { id: "b", time: 2, value: 20, interpolation: "linear" },
- { id: "c", time: 5, value: 50, interpolation: "linear" },
- ],
- },
- },
- };
- const clampedAnimations = clampAnimationsToDuration({
- animations,
- duration: 2,
- });
- expect(
- clampedAnimations?.channels["transform.rotate"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2]);
- });
-});
-
-describe("typed channel interpolation", () => {
- test("interpolates color channels from hex keyframes", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "color",
- keyframes: [
- { id: "a", time: 0, value: "#000000", interpolation: "linear" },
- { id: "b", time: 1, value: "#ffffff", interpolation: "linear" },
- ],
- },
- time: 0.5,
- fallbackValue: "#000000",
- });
- expect(typeof value).toBe("string");
- expect(value).toContain("rgba(");
- });
-
- test("uses hold behavior for discrete channels", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "discrete",
- keyframes: [
- { id: "a", time: 0, value: "normal", interpolation: "hold" },
- { id: "b", time: 2, value: "multiply", interpolation: "hold" },
- ],
- },
- time: 1.2,
- fallbackValue: "normal",
- });
- expect(value).toBe("normal");
- });
-});
-
-describe("keyframe query helpers", () => {
- test("getElementKeyframes returns flat list of all keyframes across channels", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.position.x": {
- valueKind: "number",
- keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
- },
- opacity: {
- valueKind: "number",
- keyframes: [{ id: "o-1", time: 0, value: 1, interpolation: "linear" }],
- },
- },
- };
-
- const keyframes = getElementKeyframes({ animations });
- expect(keyframes).toHaveLength(2);
- expect(keyframes.map((keyframe) => keyframe.propertyPath).sort()).toEqual([
- "opacity",
- "transform.position.x",
- ]);
- });
-
- test("getElementKeyframes returns empty array when animations are missing or channels are empty", () => {
- expect(getElementKeyframes({ animations: undefined })).toEqual([]);
- expect(
- getElementKeyframes({
- animations: {
- channels: { opacity: { valueKind: "number", keyframes: [] } },
- },
- }),
- ).toEqual([]);
- });
-
- test("hasKeyframesForPath returns true only for paths with keyframes", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.position.x": {
- valueKind: "number",
- keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
- },
- "transform.position.y": {
- valueKind: "number",
- keyframes: [],
- },
- },
- };
-
- expect(
- hasKeyframesForPath({ animations, propertyPath: "transform.position.x" }),
- ).toBe(true);
- expect(
- hasKeyframesForPath({ animations, propertyPath: "transform.position.y" }),
- ).toBe(false);
- });
-
- test("getKeyframeAtTime finds keyframe within epsilon and returns full object", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.rotate": {
- valueKind: "number",
- keyframes: [
- { id: "r-1", time: 1, value: 15, interpolation: "linear" },
- { id: "r-2", time: 2, value: 30, interpolation: "linear" },
- ],
- },
- },
- };
-
- const found = getKeyframeAtTime({
- animations,
- propertyPath: "transform.rotate",
- time: 1.0008,
- });
- expect(found?.id).toBe("r-1");
- expect(found?.value).toBe(15);
- expect(found?.propertyPath).toBe("transform.rotate");
-
- expect(
- getKeyframeAtTime({
- animations,
- propertyPath: "transform.rotate",
- time: 1.01,
- }),
- ).toBeNull();
- });
-});
diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts
index 2a340f94..a34b469b 100644
--- a/apps/web/src/lib/animation/color-channel.ts
+++ b/apps/web/src/lib/animation/color-channel.ts
@@ -2,7 +2,7 @@ import type {
AnimationPropertyPath,
ColorAnimationChannel,
ElementAnimations,
-} from "@/types/animation";
+} from "@/lib/animation/types";
export function getColorChannelForPath({
animations,
diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts
index 07efe4d8..323d9f54 100644
--- a/apps/web/src/lib/animation/effect-param-channel.ts
+++ b/apps/web/src/lib/animation/effect-param-channel.ts
@@ -1,8 +1,10 @@
-import type { Effect, EffectParamValues } from "@/types/effects";
+import type { ParamValues } from "@/lib/params";
+import type { Effect } from "@/lib/effects/types";
import type {
ElementAnimations,
+ EffectParamPath,
NumberAnimationChannel,
-} from "@/types/animation";
+} from "@/lib/animation/types";
import {
getChannel,
removeKeyframe,
@@ -11,19 +13,56 @@ import {
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
-const EFFECT_PARAM_PATH_PREFIX = "effects.";
-const EFFECT_PARAM_PATH_SUFFIX = ".params.";
+export const EFFECT_PARAM_PATH_PREFIX = "effects.";
+export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
-function buildEffectParamPath({
+export function buildEffectParamPath({
effectId,
paramKey,
}: {
effectId: string;
paramKey: string;
-}): string {
+}): EffectParamPath {
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
}
+export function isEffectParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): propertyPath is EffectParamPath {
+ return (
+ propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) &&
+ propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX)
+ );
+}
+
+export function parseEffectParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): { effectId: string; paramKey: string } | null {
+ if (!isEffectParamPath({ propertyPath })) {
+ return null;
+ }
+
+ const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length);
+ const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX);
+ if (separatorIndex <= 0) {
+ return null;
+ }
+
+ const effectId = withoutPrefix.slice(0, separatorIndex);
+ const paramKey = withoutPrefix.slice(
+ separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length,
+ );
+ if (!effectId || !paramKey) {
+ return null;
+ }
+
+ return { effectId, paramKey };
+}
+
export function resolveEffectParamsAtTime({
effect,
animations,
@@ -32,8 +71,8 @@ export function resolveEffectParamsAtTime({
effect: Effect;
animations: ElementAnimations | undefined;
localTime: number;
-}): EffectParamValues {
- const resolved: EffectParamValues = {};
+}): ParamValues {
+ const resolved: ParamValues = {};
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts
new file mode 100644
index 00000000..e77e4c73
--- /dev/null
+++ b/apps/web/src/lib/animation/graphic-param-channel.ts
@@ -0,0 +1,79 @@
+import type {
+ ElementAnimations,
+ GraphicParamPath,
+} from "@/lib/animation/types";
+import type { ParamValues } from "@/lib/params";
+import {
+ getGraphicDefinition,
+ resolveGraphicParams,
+} from "@/lib/graphics";
+import { getChannel } from "./keyframes";
+import { getChannelValueAtTime } from "./interpolation";
+
+export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
+
+export function buildGraphicParamPath({
+ paramKey,
+}: {
+ paramKey: string;
+}): GraphicParamPath {
+ return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
+}
+
+export function isGraphicParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): propertyPath is GraphicParamPath {
+ return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
+}
+
+export function parseGraphicParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): { paramKey: string } | null {
+ if (!isGraphicParamPath({ propertyPath })) {
+ return null;
+ }
+
+ const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
+ return paramKey.length > 0 ? { paramKey } : null;
+}
+
+export function resolveGraphicParamsAtTime({
+ element,
+ localTime,
+}: {
+ element: {
+ definitionId: string;
+ params: ParamValues;
+ animations?: ElementAnimations;
+ };
+ localTime: number;
+}): ParamValues {
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const baseParams = resolveGraphicParams(definition, element.params);
+ const resolved: ParamValues = { ...baseParams };
+
+ for (const param of definition.params) {
+ const path = buildGraphicParamPath({ paramKey: param.key });
+ const channel = getChannel({
+ animations: element.animations,
+ propertyPath: path,
+ });
+ if (!channel || channel.keyframes.length === 0) {
+ continue;
+ }
+
+ resolved[param.key] = getChannelValueAtTime({
+ channel,
+ time: Math.max(0, localTime),
+ fallbackValue: baseParams[param.key] ?? param.default,
+ }) as number | string | boolean;
+ }
+
+ return resolved;
+}
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
index c4396bb7..3dcddad3 100644
--- a/apps/web/src/lib/animation/index.ts
+++ b/apps/web/src/lib/animation/index.ts
@@ -1,6 +1,7 @@
export {
getChannelValueAtTime,
getNumberChannelValueAtTime,
+ getVectorChannelValueAtTime,
normalizeChannel,
} from "./interpolation";
@@ -13,6 +14,7 @@ export {
setChannel,
splitAnimationsAtTime,
upsertElementKeyframe,
+ upsertPathKeyframe,
} from "./keyframes";
export {
@@ -21,7 +23,6 @@ export {
resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
- resolveVolumeAtTime,
} from "./resolve";
export {
@@ -31,6 +32,9 @@ export {
getElementBaseValueForProperty,
isAnimationPropertyPath,
supportsAnimationProperty,
+ type AnimationPropertyDefinition,
+ type NumericSpec,
+ type NumericRange,
withElementBaseValueForProperty,
} from "./property-registry";
@@ -39,3 +43,29 @@ export {
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
+
+export {
+ buildGraphicParamPath,
+ isGraphicParamPath,
+ parseGraphicParamPath,
+ resolveGraphicParamsAtTime,
+} from "./graphic-param-channel";
+
+export {
+ isAnimationPath,
+ resolveAnimationTarget,
+ getParamValueKind,
+ getParamDefaultInterpolation,
+ type AnimationPathDescriptor,
+} from "./target-resolver";
+
+export {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ type GroupKeyframeRef,
+} from "./property-groups";
+
+export {
+ getVectorChannelForPath,
+ isVectorValue,
+} from "./vector-channel";
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
index 7a6fc6cf..c5b9ea80 100644
--- a/apps/web/src/lib/animation/interpolation.ts
+++ b/apps/web/src/lib/animation/interpolation.ts
@@ -5,7 +5,10 @@ import type {
DiscreteValue,
DiscreteAnimationChannel,
NumberAnimationChannel,
-} from "@/types/animation";
+ VectorAnimationChannel,
+ VectorValue,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
function byTimeAscending({
@@ -59,12 +62,7 @@ function parseHexColor({
const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
- if (
- red === null ||
- green === null ||
- blue === null ||
- alpha === null
- ) {
+ if (red === null || green === null || blue === null || alpha === null) {
return null;
}
@@ -77,12 +75,7 @@ function parseHexColor({
const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
const alpha = parseHexChannel({ hex: alphaHex });
- if (
- red === null ||
- green === null ||
- blue === null ||
- alpha === null
- ) {
+ if (red === null || green === null || blue === null || alpha === null) {
return null;
}
@@ -177,7 +170,10 @@ export function normalizeChannel({
} as TChannel;
}
-function evaluateChannelValueAtTime({
+function evaluateChannelValueAtTime<
+ TKeyframe extends { time: number; value: TValue },
+ TValue,
+>({
keyframes,
time,
fallbackValue,
@@ -214,7 +210,11 @@ function evaluateChannelValueAtTime {
+ if (leftKeyframe.interpolation === "hold") {
+ return leftKeyframe.value;
+ }
+
+ return {
+ x:
+ leftKeyframe.value.x +
+ (rightKeyframe.value.x - leftKeyframe.value.x) * progress,
+ y:
+ leftKeyframe.value.y +
+ (rightKeyframe.value.y - leftKeyframe.value.y) * progress,
+ };
+ },
+ });
+}
+
function getDiscreteValueAtTime({
channel,
time,
@@ -358,6 +388,17 @@ export function getChannelValueAtTime({
});
}
+ if (channel.valueKind === "vector") {
+ if (!isVectorValue(fallbackValue)) {
+ return fallbackValue;
+ }
+ return getVectorChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue: fallbackValue as VectorValue,
+ });
+ }
+
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue;
}
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
index 6626e3e8..753e31d0 100644
--- a/apps/web/src/lib/animation/keyframe-query.ts
+++ b/apps/web/src/lib/animation/keyframe-query.ts
@@ -1,9 +1,10 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type {
- AnimationPropertyPath,
+ AnimationPath,
ElementAnimations,
ElementKeyframe,
-} from "@/types/animation";
+} from "@/lib/animation/types";
+import { isAnimationPath } from "./target-resolver";
export function getElementKeyframes({
animations,
@@ -16,12 +17,16 @@ export function getElementKeyframes({
return Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
- if (!channel || channel.keyframes.length === 0) {
+ if (
+ !channel ||
+ channel.keyframes.length === 0 ||
+ !isAnimationPath({ propertyPath })
+ ) {
return [];
}
return channel.keyframes.map((keyframe) => ({
- propertyPath: propertyPath as AnimationPropertyPath,
+ propertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
@@ -36,7 +41,7 @@ export function hasKeyframesForPath({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
}): boolean {
const channel = animations?.channels[propertyPath];
return Boolean(channel && channel.keyframes.length > 0);
@@ -48,7 +53,7 @@ export function getKeyframeAtTime({
time,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
const channel = animations?.channels[propertyPath];
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index 3e3613f2..5fa99fb8 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -2,6 +2,7 @@ import type {
AnimationChannel,
AnimationInterpolation,
AnimationKeyframe,
+ AnimationPath,
AnimationPropertyPath,
AnimationValue,
AnimationValueKind,
@@ -9,15 +10,19 @@ import type {
DiscreteAnimationChannel,
ElementAnimations,
NumberAnimationChannel,
-} from "@/types/animation";
+ VectorAnimationChannel,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { generateUUID } from "@/utils/id";
+import { snapToStep } from "@/utils/math";
import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
import {
coerceAnimationValueForProperty,
getDefaultInterpolationForProperty,
getAnimationPropertyDefinition,
isAnimationPropertyPath,
+ type NumericRange,
} from "./property-registry";
function isNearlySameTime({
@@ -126,6 +131,19 @@ function buildKeyframe({
};
}
+ if (channel.valueKind === "vector") {
+ if (!isVectorValue(value)) {
+ throw new Error("Vector channel keyframes require {x, y} values");
+ }
+
+ return {
+ id,
+ time,
+ value,
+ interpolation: interpolation === "hold" ? "hold" : "linear",
+ };
+ }
+
if (typeof value !== "string" && typeof value !== "boolean") {
throw new Error(
"Discrete channel keyframes require boolean or string values",
@@ -140,32 +158,178 @@ function buildKeyframe({
};
}
-function createEmptyChannel({
- propertyPath,
+function createEmptyChannelForValueKind({
+ valueKind,
}: {
- propertyPath: AnimationPropertyPath;
+ valueKind: AnimationValueKind;
}): AnimationChannel {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- if (propertyDefinition.valueKind === "number") {
+ if (valueKind === "number") {
return {
valueKind: "number",
keyframes: [],
} satisfies NumberAnimationChannel;
}
- if (propertyDefinition.valueKind === "color") {
+ if (valueKind === "color") {
return {
valueKind: "color",
keyframes: [],
} satisfies ColorAnimationChannel;
}
+ if (valueKind === "vector") {
+ return {
+ valueKind: "vector",
+ keyframes: [],
+ } satisfies VectorAnimationChannel;
+ }
+
return {
valueKind: "discrete",
keyframes: [],
} satisfies DiscreteAnimationChannel;
}
+function clampNumericRange({
+ value,
+ numericRange,
+}: {
+ value: number;
+ numericRange: NumericRange | undefined;
+}): number {
+ if (!numericRange) {
+ return value;
+ }
+
+ const steppedValue =
+ numericRange.step != null
+ ? snapToStep({ value, step: numericRange.step })
+ : value;
+ const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
+ const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+}
+
+function coerceAnimationValueForPath({
+ value,
+ valueKind,
+ numericRange,
+}: {
+ value: AnimationValue;
+ valueKind: AnimationValueKind;
+ numericRange?: NumericRange;
+}): AnimationValue | null {
+ if (valueKind === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return clampNumericRange({ value, numericRange });
+ }
+
+ if (valueKind === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (valueKind === "vector") {
+ return isVectorValue(value) ? value : null;
+ }
+
+ return typeof value === "string" || typeof value === "boolean" ? value : null;
+}
+
+export function upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ valueKind,
+ defaultInterpolation,
+ numericRange,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericRange;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceAnimationValueForPath({
+ value,
+ valueKind,
+ numericRange,
+ });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const channel = getChannel({ animations, propertyPath });
+ const targetChannel =
+ channel && channel.valueKind === valueKind
+ ? channel
+ : createEmptyChannelForValueKind({ valueKind });
+ const updatedChannel = upsertKeyframe({
+ channel: targetChannel,
+ time,
+ value: coercedValue,
+ interpolation: interpolation ?? defaultInterpolation,
+ keyframeId,
+ });
+
+ return (
+ setChannel({
+ animations,
+ propertyPath,
+ channel: updatedChannel,
+ }) ?? { channels: {} }
+ );
+}
+
+export function upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+ });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value: coercedValue,
+ interpolation,
+ keyframeId,
+ valueKind: propertyDefinition.valueKind,
+ defaultInterpolation: getDefaultInterpolationForProperty({
+ propertyPath,
+ }),
+ numericRange: propertyDefinition.numericRange,
+ });
+}
+
+
export function upsertKeyframe({
channel,
time,
@@ -516,62 +680,13 @@ export function splitAnimationsAtTime({
};
}
-export function upsertElementKeyframe({
- animations,
- propertyPath,
- time,
- value,
- interpolation,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- time: number;
- value: AnimationValue;
- interpolation?: AnimationInterpolation;
- keyframeId?: string;
-}): ElementAnimations | undefined {
- const coercedValue = coerceAnimationValueForProperty({
- propertyPath,
- value,
- });
- if (coercedValue === null) {
- return animations;
- }
-
- const defaultInterpolation = getDefaultInterpolationForProperty({
- propertyPath,
- });
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- const channel = getChannel({ animations, propertyPath });
- const targetChannel =
- channel && channel.valueKind === propertyDefinition.valueKind
- ? channel
- : createEmptyChannel({ propertyPath });
- const updatedChannel = upsertKeyframe({
- channel: targetChannel,
- time,
- value: coercedValue,
- interpolation: interpolation ?? defaultInterpolation,
- keyframeId,
- });
-
- return (
- setChannel({
- animations,
- propertyPath,
- channel: updatedChannel,
- }) ?? { channels: {} }
- );
-}
-
export function removeElementKeyframe({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}): ElementAnimations | undefined {
const channel = getChannel({ animations, propertyPath });
@@ -593,7 +708,7 @@ export function retimeElementKeyframe({
time,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
time: number;
}): ElementAnimations | undefined {
diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts
index 67166156..859da863 100644
--- a/apps/web/src/lib/animation/number-channel.ts
+++ b/apps/web/src/lib/animation/number-channel.ts
@@ -2,7 +2,7 @@ import type {
AnimationPropertyPath,
ElementAnimations,
NumberAnimationChannel,
-} from "@/types/animation";
+} from "@/lib/animation/types";
export function getNumberChannelForPath({
animations,
diff --git a/apps/web/src/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts
new file mode 100644
index 00000000..61b4fc4a
--- /dev/null
+++ b/apps/web/src/lib/animation/property-groups.ts
@@ -0,0 +1,39 @@
+import type {
+ AnimationPropertyGroup,
+ AnimationPropertyPath,
+ ElementAnimations,
+} from "@/lib/animation/types";
+import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
+import { getKeyframeAtTime } from "./keyframe-query";
+
+export interface GroupKeyframeRef {
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}
+
+export function getGroupKeyframesAtTime({
+ animations,
+ group,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ group: AnimationPropertyGroup;
+ time: number;
+}): GroupKeyframeRef[] {
+ return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => {
+ const keyframe = getKeyframeAtTime({ animations, propertyPath, time });
+ return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : [];
+ });
+}
+
+export function hasGroupKeyframeAtTime({
+ animations,
+ group,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ group: AnimationPropertyGroup;
+ time: number;
+}): boolean {
+ return getGroupKeyframesAtTime({ animations, group, time }).length > 0;
+}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
index 361ae3ba..0b28e783 100644
--- a/apps/web/src/lib/animation/property-registry.ts
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -4,25 +4,35 @@ import type {
AnimationValue,
AnimationValueKind,
DiscreteValue,
-} from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+ VectorValue,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
+import type { TimelineElement } from "@/lib/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
- DEFAULT_TEXT_BACKGROUND,
} from "@/constants/text-constants";
-import { isVisualElement } from "@/lib/timeline/element-utils";
+import {
+ canElementHaveAudio,
+ isVisualElement,
+} from "@/lib/timeline/element-utils";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { snapToStep } from "@/utils/math";
-interface NumericRange {
+export interface NumericSpec {
min?: number;
max?: number;
+ step?: number;
}
-interface AnimationPropertyDefinition {
+export type NumericRange = NumericSpec;
+
+export interface AnimationPropertyDefinition {
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
- numericRange?: NumericRange;
+ numericRange?: NumericSpec;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
setValue: ({
@@ -38,58 +48,57 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
- "transform.position.x": {
- valueKind: "number",
+ "transform.position": {
+ valueKind: "vector",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.x : null,
+ isVisualElement(element) ? element.transform.position : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
- position: { ...element.transform.position, x: value as number },
+ position: value as VectorValue,
},
}
: element,
},
- "transform.position.y": {
+ "transform.scaleX": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.y : null,
+ isVisualElement(element) ? element.transform.scaleX : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
- transform: {
- ...element.transform,
- position: { ...element.transform.position, y: value as number },
- },
+ transform: { ...element.transform, scaleX: value as number },
}
: element,
},
- "transform.scale": {
+ "transform.scaleY": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: MIN_TRANSFORM_SCALE },
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scale : null,
+ isVisualElement(element) ? element.transform.scaleY : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
- transform: { ...element.transform, scale: value as number },
+ transform: { ...element.transform, scaleY: value as number },
}
: element,
},
"transform.rotate": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { min: -360, max: 360, step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
@@ -104,7 +113,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
opacity: {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0, max: 1 },
+ numericRange: { min: 0, max: 1, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
@@ -116,12 +125,12 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
volume: {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0, max: 1 },
- supportsElement: ({ element }) => element.type === "audio",
+ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
+ supportsElement: ({ element }) => canElementHaveAudio(element),
getValue: ({ element }) =>
- element.type === "audio" ? element.volume : null,
+ canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) =>
- element.type === "audio"
+ canElementHaveAudio(element)
? { ...element, volume: value as number }
: element,
},
@@ -152,11 +161,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.paddingX": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0 },
+ numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
+ ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -169,11 +178,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.paddingY": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0 },
+ numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY)
+ ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -186,10 +195,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.offsetX": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX)
+ ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -202,10 +212,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.offsetY": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY)
+ ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -218,7 +229,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.cornerRadius": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX },
+ numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
@@ -304,20 +315,24 @@ export function getDefaultInterpolationForProperty({
return propertyDefinition.defaultInterpolation;
}
-function clampNumericRange({
+function applyNumericSpec({
value,
numericRange,
}: {
value: number;
- numericRange: NumericRange | undefined;
+ numericRange: NumericSpec | undefined;
}): number {
if (!numericRange) {
return value;
}
+ const steppedValue =
+ numericRange.step != null
+ ? snapToStep({ value, step: numericRange.step })
+ : value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, value));
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
}
export function coerceAnimationValueForProperty({
@@ -334,7 +349,7 @@ export function coerceAnimationValueForProperty({
return null;
}
- return clampNumericRange({
+ return applyNumericSpec({
value,
numericRange: propertyDefinition.numericRange,
});
@@ -344,6 +359,10 @@ export function coerceAnimationValueForProperty({
return typeof value === "string" ? value : null;
}
+ if (propertyDefinition.valueKind === "vector") {
+ return isVectorValue(value) ? value : null;
+ }
+
if (typeof value === "string" || typeof value === "boolean") {
return value as DiscreteValue;
}
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
index 147be1b3..8709627d 100644
--- a/apps/web/src/lib/animation/resolve.ts
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -1,8 +1,16 @@
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { Transform } from "@/types/timeline";
-import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation";
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { Transform } from "@/lib/rendering";
+import {
+ getColorValueAtTime,
+ getNumberChannelValueAtTime,
+ getVectorChannelValueAtTime,
+} from "./interpolation";
import { getColorChannelForPath } from "./color-channel";
import { getNumberChannelForPath } from "./number-channel";
+import { getVectorChannelForPath } from "./vector-channel";
export function getElementLocalTime({
timelineTime,
@@ -36,31 +44,29 @@ export function resolveTransformAtTime({
}): Transform {
const safeLocalTime = Math.max(0, localTime);
return {
- position: {
- x: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.position.x",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.position.x,
- }),
- y: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.position.y",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.position.y,
- }),
- },
- scale: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
+ position: getVectorChannelValueAtTime({
+ channel: getVectorChannelForPath({
animations,
- propertyPath: "transform.scale",
+ propertyPath: "transform.position",
}),
time: safeLocalTime,
- fallbackValue: baseTransform.scale,
+ fallbackValue: baseTransform.position,
+ }),
+ scaleX: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.scaleX",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.scaleX,
+ }),
+ scaleY: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.scaleY",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.scaleY,
}),
rotate: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
@@ -127,22 +133,3 @@ export function resolveColorAtTime({
fallbackValue: baseColor,
});
}
-
-export function resolveVolumeAtTime({
- baseVolume,
- animations,
- localTime,
-}: {
- baseVolume: number;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): number {
- return getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "volume",
- }),
- time: Math.max(0, localTime),
- fallbackValue: baseVolume,
- });
-}
diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts
new file mode 100644
index 00000000..e801bc68
--- /dev/null
+++ b/apps/web/src/lib/animation/target-resolver.ts
@@ -0,0 +1,279 @@
+import type {
+ AnimationInterpolation,
+ AnimationPath,
+ AnimationValue,
+ AnimationValueKind,
+} from "@/lib/animation/types";
+import {
+ parseEffectParamPath,
+ isEffectParamPath,
+} from "@/lib/animation/effect-param-channel";
+import {
+ isGraphicParamPath,
+ parseGraphicParamPath,
+} from "@/lib/animation/graphic-param-channel";
+import type { ParamDefinition } from "@/lib/params";
+import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
+import { getGraphicDefinition } from "@/lib/graphics";
+import type { TimelineElement } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import { snapToStep } from "@/utils/math";
+import {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+
+export interface AnimationPathDescriptor {
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericSpec;
+ getBaseValue(): AnimationValue | null;
+ setBaseValue(value: AnimationValue): TimelineElement;
+}
+
+export function getParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationValueKind {
+ if (param.type === "number") {
+ return "number";
+ }
+
+ if (param.type === "color") {
+ return "color";
+ }
+
+ return "discrete";
+}
+
+export function getParamDefaultInterpolation({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationInterpolation {
+ return param.type === "number" || param.type === "color" ? "linear" : "hold";
+}
+
+function getParamNumericRange({
+ param,
+}: {
+ param: ParamDefinition;
+}): NumericSpec | undefined {
+ if (param.type !== "number") {
+ return undefined;
+ }
+
+ return {
+ min: param.min,
+ max: param.max,
+ step: param.step,
+ };
+}
+
+function coerceParamValue({
+ param,
+ value,
+}: {
+ param: ParamDefinition;
+ value: AnimationValue;
+}): number | string | boolean | null {
+ if (param.type === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ const steppedValue = snapToStep({ value, step: param.step });
+ const minValue = param.min;
+ const maxValue = param.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+ }
+
+ if (param.type === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (param.type === "boolean") {
+ return typeof value === "boolean" ? value : null;
+ }
+
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ return param.options.some((option) => option.value === value) ? value : null;
+}
+
+function buildGraphicParamDescriptor({
+ element,
+ paramKey,
+}: {
+ element: TimelineElement;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (element.type !== "graphic") {
+ return null;
+ }
+
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRange: getParamNumericRange({ param }),
+ getBaseValue: () => element.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceParamValue({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ params: {
+ ...element.params,
+ [param.key]: coercedValue,
+ },
+ };
+ },
+ };
+}
+
+function buildEffectParamDescriptor({
+ element,
+ effectId,
+ paramKey,
+}: {
+ element: TimelineElement;
+ effectId: string;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (!isVisualElement(element)) {
+ return null;
+ }
+
+ const effect = element.effects?.find((candidate) => candidate.id === effectId);
+ if (!effect) {
+ return null;
+ }
+
+ registerDefaultEffects();
+ const definition = effectsRegistry.get(effect.type);
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRange: getParamNumericRange({ param }),
+ getBaseValue: () => effect.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceParamValue({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ effects:
+ element.effects?.map((candidate) =>
+ candidate.id !== effectId
+ ? candidate
+ : {
+ ...candidate,
+ params: {
+ ...candidate.params,
+ [param.key]: coercedValue,
+ },
+ },
+ ) ?? element.effects,
+ };
+ },
+ };
+}
+
+export function isAnimationPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): propertyPath is AnimationPath {
+ return (
+ isAnimationPropertyPath({ propertyPath }) ||
+ isGraphicParamPath({ propertyPath }) ||
+ isEffectParamPath({ propertyPath })
+ );
+}
+
+export function resolveAnimationTarget({
+ element,
+ path,
+}: {
+ element: TimelineElement;
+ path: AnimationPath;
+}): AnimationPathDescriptor | null {
+ if (isAnimationPropertyPath({ propertyPath: path })) {
+ const propertyDefinition = getAnimationPropertyDefinition({
+ propertyPath: path,
+ });
+ if (!propertyDefinition.supportsElement({ element })) {
+ return null;
+ }
+
+ return {
+ valueKind: propertyDefinition.valueKind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ numericRange: propertyDefinition.numericRange,
+ getBaseValue: () =>
+ getElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ }),
+ setBaseValue: (value) => {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath: path,
+ value,
+ });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return withElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ value: coercedValue,
+ });
+ },
+ };
+ }
+
+ const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
+ if (graphicParamTarget) {
+ return buildGraphicParamDescriptor({
+ element,
+ paramKey: graphicParamTarget.paramKey,
+ });
+ }
+
+ const effectParamTarget = parseEffectParamPath({ propertyPath: path });
+ if (effectParamTarget) {
+ return buildEffectParamDescriptor({
+ element,
+ effectId: effectParamTarget.effectId,
+ paramKey: effectParamTarget.paramKey,
+ });
+ }
+
+ return null;
+}
diff --git a/apps/web/src/types/animation.ts b/apps/web/src/lib/animation/types.ts
similarity index 68%
rename from apps/web/src/types/animation.ts
rename to apps/web/src/lib/animation/types.ts
index 825894c5..913911d0 100644
--- a/apps/web/src/types/animation.ts
+++ b/apps/web/src/lib/animation/types.ts
@@ -1,7 +1,7 @@
export const ANIMATION_PROPERTY_PATHS = [
- "transform.position.x",
- "transform.position.y",
- "transform.scale",
+ "transform.position",
+ "transform.scaleX",
+ "transform.scaleY",
"transform.rotate",
"opacity",
"volume",
@@ -15,10 +15,24 @@ export const ANIMATION_PROPERTY_PATHS = [
] as const;
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
+export type GraphicParamPath = `params.${string}`;
+export type EffectParamPath = `effects.${string}.params.${string}`;
+export type AnimationPath =
+ | AnimationPropertyPath
+ | GraphicParamPath
+ | EffectParamPath;
-export type AnimationValueKind = "number" | "color" | "discrete";
+export const ANIMATION_PROPERTY_GROUPS = {
+ "transform.scale": ["transform.scaleX", "transform.scaleY"],
+} as const satisfies Record>;
+
+export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
+
+export type VectorValue = { x: number; y: number };
+
+export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
export type DiscreteValue = boolean | string;
-export type AnimationValue = number | string | boolean;
+export type AnimationValue = number | string | boolean | VectorValue;
export type ContinuousKeyframeInterpolation = "linear" | "hold";
export type DiscreteKeyframeInterpolation = "hold";
@@ -45,10 +59,14 @@ export interface ColorKeyframe
export interface DiscreteKeyframe
extends BaseAnimationKeyframe {}
+export interface VectorKeyframe
+ extends BaseAnimationKeyframe {}
+
export type AnimationKeyframe =
| NumberKeyframe
| ColorKeyframe
- | DiscreteKeyframe;
+ | DiscreteKeyframe
+ | VectorKeyframe;
interface BaseAnimationChannel<
TValueKind extends AnimationValueKind,
@@ -67,10 +85,14 @@ export interface ColorAnimationChannel
export interface DiscreteAnimationChannel
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
+export interface VectorAnimationChannel
+ extends BaseAnimationChannel<"vector", VectorKeyframe> {}
+
export type AnimationChannel =
| NumberAnimationChannel
| ColorAnimationChannel
- | DiscreteAnimationChannel;
+ | DiscreteAnimationChannel
+ | VectorAnimationChannel;
export type ElementAnimationChannelMap = Record<
string,
@@ -82,7 +104,7 @@ export interface ElementAnimations {
}
export interface ElementKeyframe {
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
id: string;
time: number;
value: AnimationValue;
@@ -92,6 +114,6 @@ export interface ElementKeyframe {
export interface SelectedKeyframeRef {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}
diff --git a/apps/web/src/lib/animation/vector-channel.ts b/apps/web/src/lib/animation/vector-channel.ts
new file mode 100644
index 00000000..50c62e40
--- /dev/null
+++ b/apps/web/src/lib/animation/vector-channel.ts
@@ -0,0 +1,72 @@
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ VectorAnimationChannel,
+ VectorValue,
+} from "@/lib/animation/types";
+
+export function isVectorValue(value: unknown): value is VectorValue {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "x" in value &&
+ "y" in value &&
+ typeof (value as VectorValue).x === "number" &&
+ typeof (value as VectorValue).y === "number"
+ );
+}
+
+export function getVectorChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): VectorAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "vector") {
+ return undefined;
+ }
+ return channel;
+}
+
+export function getVectorChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: VectorAnimationChannel | undefined;
+ time: number;
+ fallbackValue: VectorValue;
+}): VectorValue {
+ if (!channel || channel.keyframes.length === 0) {
+ return fallbackValue;
+ }
+
+ const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time);
+ const first = keyframes[0];
+ const last = keyframes[keyframes.length - 1];
+
+ if (!first || !last) return fallbackValue;
+ if (time <= first.time) return first.value;
+ if (time >= last.time) return last.value;
+
+ for (let i = 0; i < keyframes.length - 1; i++) {
+ const left = keyframes[i];
+ const right = keyframes[i + 1];
+ if (time < left.time || time > right.time) continue;
+
+ if (left.interpolation === "hold") return left.value;
+
+ const span = right.time - left.time;
+ if (span === 0) return right.value;
+
+ const t = (time - left.time) / span;
+ return {
+ x: left.value.x + (right.value.x - left.value.x) * t,
+ y: left.value.y + (right.value.y - left.value.y) * t,
+ };
+ }
+
+ return last.value;
+}
diff --git a/apps/web/src/lib/blog/query.ts b/apps/web/src/lib/blog/query.ts
index f814eeaf..5f7c7474 100644
--- a/apps/web/src/lib/blog/query.ts
+++ b/apps/web/src/lib/blog/query.ts
@@ -5,7 +5,7 @@ import type {
MarblePost,
MarblePostList,
MarbleTagList,
-} from "@/types/blog";
+} from "@/lib/blog/types";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
diff --git a/apps/web/src/types/blog.ts b/apps/web/src/lib/blog/types.ts
similarity index 100%
rename from apps/web/src/types/blog.ts
rename to apps/web/src/lib/blog/types.ts
diff --git a/apps/web/src/lib/cancel-interaction.ts b/apps/web/src/lib/cancel-interaction.ts
new file mode 100644
index 00000000..0cb07f4f
--- /dev/null
+++ b/apps/web/src/lib/cancel-interaction.ts
@@ -0,0 +1,24 @@
+type CancelFn = () => void;
+
+const cancellers = new Set();
+
+export function registerCanceller({ fn }: { fn: CancelFn }): () => void {
+ cancellers.add(fn);
+
+ return () => {
+ cancellers.delete(fn);
+ };
+}
+
+export function cancelInteraction(): boolean {
+ if (cancellers.size === 0) return false;
+
+ const activeCancellers = Array.from(cancellers);
+ cancellers.clear();
+
+ for (const cancel of activeCancellers) {
+ cancel();
+ }
+
+ return true;
+}
diff --git a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
deleted file mode 100644
index d2356a39..00000000
--- a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
+++ /dev/null
@@ -1,433 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test";
-import { EditorCore } from "@/core";
-import type { TimelineTrack, VideoElement } from "@/types/timeline";
-import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
-import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
-import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
-import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
-import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
-import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
-import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
-import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
-
-type MockEditor = {
- timeline: {
- getTracks: () => TimelineTrack[];
- updateTracks: (tracks: TimelineTrack[]) => void;
- };
- selection: {
- getSelectedElements: () => { trackId: string; elementId: string }[];
- setSelectedElements: ({
- elements,
- }: {
- elements: { trackId: string; elementId: string }[];
- }) => void;
- };
-};
-
-const originalGetInstance = EditorCore.getInstance;
-
-function mockEditorCore({ editor }: { editor: MockEditor }): void {
- (
- EditorCore as unknown as {
- getInstance: () => EditorCore;
- }
- ).getInstance = () => editor as unknown as EditorCore;
-}
-
-function restoreEditorCore(): void {
- (
- EditorCore as unknown as {
- getInstance: typeof EditorCore.getInstance;
- }
- ).getInstance = originalGetInstance;
-}
-
-function buildVideoElement(): VideoElement {
- return {
- id: "element-1",
- name: "Clip",
- type: "video",
- mediaId: "media-1",
- duration: 8,
- startTime: 1,
- trimStart: 0,
- trimEnd: 0,
- transform: DEFAULT_TRANSFORM,
- opacity: 1,
- animations: {
- channels: {
- "transform.scale": {
- valueKind: "number",
- keyframes: [
- { id: "kf-a", time: 0, value: 1, interpolation: "linear" },
- { id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
- { id: "kf-c", time: 6, value: 2, interpolation: "linear" },
- ],
- },
- },
- },
- };
-}
-
-function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
- return [
- {
- id: "track-1",
- name: "Main",
- type: "video",
- elements: [element],
- isMain: true,
- muted: false,
- hidden: false,
- },
- ];
-}
-
-afterEach(() => {
- restoreEditorCore();
-});
-
-describe("keyframe-aware timeline commands", () => {
- test("duration updates clamp keyframes beyond the new duration", () => {
- const tracks = buildTracks({ element: buildVideoElement() });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new UpdateElementDurationCommand({
- trackId: "track-1",
- elementId: "element-1",
- duration: 3,
- }).execute();
-
- const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
- expect(
- updatedElement?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 3]);
- });
-
- test("trim updates clamp keyframes when duration is changed", () => {
- const tracks = buildTracks({ element: buildVideoElement() });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new UpdateElementTrimCommand({
- elementId: "element-1",
- trimStart: 0,
- trimEnd: 0,
- startTime: 1,
- duration: 2,
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- expect(updatedElement.duration).toBe(2);
- expect(
- updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0]);
- });
-
- test("split rebases right-side keyframes and keeps continuity at split time", () => {
- const tracks = buildTracks({ element: buildVideoElement() });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new SplitElementsCommand({
- elements: [{ trackId: "track-1", elementId: "element-1" }],
- splitTime: 5,
- }).execute();
-
- const leftElement = updatedTracks[0].elements.find(
- (element) => element.id === "element-1",
- ) as VideoElement;
- const rightElement = updatedTracks[0].elements.find(
- (element) => element.id !== "element-1",
- ) as VideoElement;
-
- expect(
- leftElement.animations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 3, 4]);
- expect(
- rightElement.animations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2]);
- expect(
- rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
- ).toBeCloseTo(5 / 3, 4);
- });
-
- test("duplicate creates independent keyframe ids for copied element", () => {
- const tracks = buildTracks({ element: buildVideoElement() });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
- setSelectedElements: () => {},
- },
- },
- });
-
- new DuplicateElementsCommand({
- elements: [{ trackId: "track-1", elementId: "element-1" }],
- }).execute();
-
- const originalElement = updatedTracks.find(
- (track) => track.id === "track-1",
- )?.elements[0] as VideoElement;
- const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
- const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
-
- expect(duplicatedElement).toBeDefined();
- expect(
- duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 3, 6]);
- expect(
- duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
- ).not.toBe(
- originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
- );
- });
-});
-
-describe("generic keyframe commands", () => {
- test("upsert adds or updates keyframe at target time", () => {
- const element = buildVideoElement();
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new UpsertKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "transform.scale",
- time: 2,
- value: 2.5,
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- const keyframes =
- updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
- const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
- expect(atTwo?.value).toBe(2.5);
- });
-
- test("remove deletes keyframe by id", () => {
- const element = buildVideoElement();
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new RemoveKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "transform.scale",
- keyframeId: "kf-b",
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- const keyframes =
- updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
- expect(keyframes).toHaveLength(2);
- expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
- expect(updatedElement.transform.scale).toBe(1);
- });
-
- test("remove persists value to base property when channel becomes empty", () => {
- const element: VideoElement = {
- ...buildVideoElement(),
- transform: {
- ...DEFAULT_TRANSFORM,
- scale: 1,
- },
- animations: {
- channels: {
- "transform.scale": {
- valueKind: "number",
- keyframes: [
- {
- id: "only-scale",
- time: 2,
- value: 1.43,
- interpolation: "linear",
- },
- ],
- },
- },
- },
- };
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new RemoveKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "transform.scale",
- keyframeId: "only-scale",
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- expect(updatedElement.transform.scale).toBe(1.43);
- expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
- });
-
- test("upsert supports non-transform paths like opacity", () => {
- const element = buildVideoElement();
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new UpsertKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "opacity",
- time: 1,
- value: 0.35,
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- const opacityChannel = updatedElement.animations?.channels.opacity;
- expect(opacityChannel?.valueKind).toBe("number");
- expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
- });
-
- test("retime moves keyframe to new time", () => {
- const element = buildVideoElement();
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new RetimeKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "transform.scale",
- keyframeId: "kf-b",
- nextTime: 4,
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
- (existingKeyframe) => existingKeyframe.id === "kf-b",
- );
- expect(keyframe?.time).toBe(4);
- });
-});
diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts
index a2119fc1..03de07c4 100644
--- a/apps/web/src/lib/commands/index.ts
+++ b/apps/web/src/lib/commands/index.ts
@@ -1,6 +1,5 @@
export { Command } from "./base-command";
export { BatchCommand } from "./batch-command";
-export { PreviewTracker } from "./preview-tracker";
export * from "./timeline";
export * from "./media";
diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts
index ddcca426..a3dec0e6 100644
--- a/apps/web/src/lib/commands/media/add-media-asset.ts
+++ b/apps/web/src/lib/commands/media/add-media-asset.ts
@@ -1,8 +1,10 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { MediaAsset } from "@/types/assets";
+import { toast } from "sonner";
+import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
+import { hasMediaId } from "@/lib/timeline/element-utils";
export class AddMediaAssetCommand extends Command {
private assetId: string;
@@ -37,6 +39,36 @@ export class AddMediaAssetCommand extends Command {
})
.catch((error) => {
console.error("Failed to save media item:", error);
+
+ const currentAssets = editor.media.getAssets();
+ editor.media.setAssets({
+ assets: currentAssets.filter((asset) => asset.id !== this.assetId),
+ });
+
+ const currentTracks = editor.timeline.getTracks();
+ const orphanedElements: Array<{ trackId: string; elementId: string }> =
+ [];
+
+ for (const track of currentTracks) {
+ for (const element of track.elements) {
+ if (hasMediaId(element) && element.mediaId === this.assetId) {
+ orphanedElements.push({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ }
+ }
+ }
+
+ if (orphanedElements.length > 0) {
+ editor.timeline.deleteElements({ elements: orphanedElements });
+ }
+
+ if (storageService.isQuotaExceededError({ error })) {
+ toast.error("Not enough browser storage", {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
});
}
diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts
index ee9ffa2d..f519baef 100644
--- a/apps/web/src/lib/commands/media/remove-media-asset.ts
+++ b/apps/web/src/lib/commands/media/remove-media-asset.ts
@@ -1,10 +1,10 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { MediaAsset } from "@/types/assets";
+import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null;
@@ -33,6 +33,13 @@ export class RemoveMediaAssetCommand extends Command {
return;
}
+ if (this.removedAsset.url) {
+ URL.revokeObjectURL(this.removedAsset.url);
+ }
+ if (this.removedAsset.thumbnailUrl) {
+ URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
+ }
+
videoCache.clearVideo({ mediaId: this.assetId });
editor.media.setAssets({
@@ -63,23 +70,30 @@ export class RemoveMediaAssetCommand extends Command {
undo(): void {
const editor = EditorCore.getInstance();
- if (this.savedAssets) {
- editor.media.setAssets({ assets: this.savedAssets });
- }
+ if (this.savedAssets && this.removedAsset) {
+ const restoredAsset: MediaAsset = {
+ ...this.removedAsset,
+ url: URL.createObjectURL(this.removedAsset.file),
+ };
- if (this.savedTracks) {
- editor.timeline.updateTracks(this.savedTracks);
- }
+ editor.media.setAssets({
+ assets: this.savedAssets.map((a) =>
+ a.id === this.assetId ? restoredAsset : a,
+ ),
+ });
- if (this.removedAsset) {
storageService
.saveMediaAsset({
projectId: this.projectId,
- mediaAsset: this.removedAsset,
+ mediaAsset: restoredAsset,
})
.catch((error) => {
console.error("Failed to restore media item on undo:", error);
});
}
+
+ if (this.savedTracks) {
+ editor.timeline.updateTracks(this.savedTracks);
+ }
}
}
diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts
index b7ab75ee..b16431c1 100644
--- a/apps/web/src/lib/commands/project/update-project-settings.ts
+++ b/apps/web/src/lib/commands/project/update-project-settings.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TProject, TProjectSettings } from "@/types/project";
+import type { TProject, TProjectSettings } from "@/lib/project/types";
export class UpdateProjectSettingsCommand extends Command {
private savedSettings: TProjectSettings | null = null;
diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts
index 5fea5d53..28bda2fd 100644
--- a/apps/web/src/lib/commands/scene/create-scene.ts
+++ b/apps/web/src/lib/commands/scene/create-scene.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { buildDefaultScene } from "@/lib/scenes";
export class CreateSceneCommand extends Command {
diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts
index 83066e24..58c790e6 100644
--- a/apps/web/src/lib/commands/scene/delete-scene.ts
+++ b/apps/web/src/lib/commands/scene/delete-scene.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
export class DeleteSceneCommand extends Command {
diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts
index 64382f03..28d7a45c 100644
--- a/apps/web/src/lib/commands/scene/move-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/move-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts
index a3029366..19515b97 100644
--- a/apps/web/src/lib/commands/scene/remove-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/remove-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import {
getFrameTime,
diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts
index dc62369d..f1d65e27 100644
--- a/apps/web/src/lib/commands/scene/rename-scene.ts
+++ b/apps/web/src/lib/commands/scene/rename-scene.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
export class RenameSceneCommand extends Command {
diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts
index ac76d8fc..518a30aa 100644
--- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/toggle-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts
index 5ce91a36..6aeef42b 100644
--- a/apps/web/src/lib/commands/scene/update-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/update-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { Bookmark, TScene } from "@/types/timeline";
+import type { Bookmark, TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index 800cbb90..00758eb7 100644
--- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -4,7 +4,7 @@ import type {
TimelineTrack,
TimelineElement,
ClipboardItem,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
index 7fb46c36..4b439870 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
index 97b475f4..207e2373 100644
--- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
index 5299749d..34ca12ab 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
import { buildDefaultEffectInstance } from "@/lib/effects";
function addEffectToElement({
diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
index b475474f..1b309f13 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function removeEffectFromElement({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
index 81c66503..d946be18 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function reorderEffectsOnElement({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
index 70bf955d..25e7c356 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
export function toggleEffectOnElement({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
index d9bf0aad..c3443c22 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
@@ -1,8 +1,8 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { EffectParamValues } from "@/types/effects";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { ParamValues } from "@/lib/params";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function updateEffectParamsOnElement({
element,
@@ -11,7 +11,7 @@ function updateEffectParamsOnElement({
}: {
element: VisualElement;
effectId: string;
- params: Partial;
+ params: Partial;
}): VisualElement {
const currentEffects = element.effects ?? [];
const updated = currentEffects.map((effect) => {
@@ -36,7 +36,7 @@ export class UpdateClipEffectParamsCommand extends Command {
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
- private readonly params: Partial;
+ private readonly params: Partial;
constructor({
trackId,
@@ -47,7 +47,7 @@ export class UpdateClipEffectParamsCommand extends Command {
trackId: string;
elementId: string;
effectId: string;
- params: Partial;
+ params: Partial;
}) {
super();
this.trackId = trackId;
@@ -65,12 +65,12 @@ export class UpdateClipEffectParamsCommand extends Command {
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
- update: (element) => {
- return updateEffectParamsOnElement({
- element: element as VisualElement,
- effectId: this.effectId,
- params: this.params,
- });
+ update: (element) => {
+ return updateEffectParamsOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ params: this.params,
+ });
},
});
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 413d1adf..899754fb 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -11,3 +11,5 @@ export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
export * from "./effects";
+export * from "./masks";
+export * from "./retime";
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index 68fc9841..acd72b66 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -6,7 +6,7 @@ import type {
TimelineElement,
TrackType,
ElementType,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import {
requiresMediaId,
@@ -19,8 +19,9 @@ import {
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
-import type { MediaAsset } from "@/types/assets";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { MediaAsset } from "@/lib/media/types";
+import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -168,6 +169,14 @@ export class InsertElementCommand extends Command {
return false;
}
+ if (element.type === "graphic") {
+ registerDefaultGraphics();
+ if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
+ console.error("Graphic element must have a valid definitionId");
+ return false;
+ }
+ }
+
if (element.type === "text" && !element.content) {
console.error("Text element must have content");
return false;
@@ -344,9 +353,6 @@ export class InsertElementCommand extends Command {
}: {
element: { type: ElementType };
}): TrackType {
- if (element.type === "video" || element.type === "image") {
- return "video";
- }
- return element.type;
+ return ELEMENT_TRACK_MAP[element.type];
}
}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
index d4922532..984a5ed7 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
@@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command";
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
export class RemoveEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
index f12f9829..eb5cf742 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -2,15 +2,13 @@ import { EditorCore } from "@/core";
import {
getChannel,
getChannelValueAtTime,
- getElementBaseValueForProperty,
removeElementKeyframe,
- supportsAnimationProperty,
- withElementBaseValueForProperty,
+ resolveAnimationTarget,
} from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { updateElementInTracks } from "@/lib/timeline";
-import type { AnimationPropertyPath } from "@/types/animation";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { AnimationPath, AnimationValue } from "@/lib/animation/types";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
function sampleValueBeforeRemoval({
element,
@@ -18,9 +16,9 @@ function sampleValueBeforeRemoval({
keyframeId,
}: {
element: TimelineElement;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
-}): number | null {
+}): AnimationValue | null {
const channel = getChannel({
animations: element.animations,
propertyPath,
@@ -32,17 +30,20 @@ function sampleValueBeforeRemoval({
return null;
}
- const baseValue = getElementBaseValueForProperty({ element, propertyPath });
- if (baseValue === null || typeof baseValue !== "number") {
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ if (!target) {
+ return null;
+ }
+ const baseValue = target.getBaseValue();
+ if (baseValue === null) {
return null;
}
- const sampled = getChannelValueAtTime({
+ return getChannelValueAtTime({
channel,
time: keyframe.time,
fallbackValue: baseValue,
});
- return typeof sampled === "number" ? sampled : null;
}
function removeKeyframeAndPersist({
@@ -51,9 +52,14 @@ function removeKeyframeAndPersist({
keyframeId,
}: {
element: TimelineElement;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}): TimelineElement {
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ if (!target) {
+ return element;
+ }
+
const valueBefore = sampleValueBeforeRemoval({
element,
propertyPath,
@@ -71,11 +77,7 @@ function removeKeyframeAndPersist({
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
const baseElement = shouldPersistToBase
- ? withElementBaseValueForProperty({
- element,
- propertyPath,
- value: valueBefore,
- })
+ ? target.setBaseValue(valueBefore)
: element;
return { ...baseElement, animations: nextAnimations };
@@ -85,7 +87,7 @@ export class RemoveKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly propertyPath: AnimationPropertyPath;
+ private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
constructor({
@@ -96,7 +98,7 @@ export class RemoveKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}) {
super();
@@ -114,11 +116,6 @@ export class RemoveKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
- elementPredicate: (element) =>
- supportsAnimationProperty({
- element,
- propertyPath: this.propertyPath,
- }),
update: (element) =>
removeKeyframeAndPersist({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
index 5328419f..9e912f27 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -1,15 +1,15 @@
import { EditorCore } from "@/core";
-import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
+import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { updateElementInTracks } from "@/lib/timeline";
-import type { AnimationPropertyPath } from "@/types/animation";
-import type { TimelineTrack } from "@/types/timeline";
+import type { AnimationPath } from "@/lib/animation/types";
+import type { TimelineTrack } from "@/lib/timeline";
export class RetimeKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly propertyPath: AnimationPropertyPath;
+ private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
private readonly nextTime: number;
@@ -22,7 +22,7 @@ export class RetimeKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
nextTime: number;
}) {
@@ -42,14 +42,12 @@ export class RetimeKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
- elementPredicate: (element) =>
- supportsAnimationProperty({
- element,
- propertyPath: this.propertyPath,
- }),
update: (element) => {
+ if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
+ return element;
+ }
+
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
- if (!Number.isFinite(boundedTime)) return element;
return {
...element,
animations: retimeElementKeyframe({
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
index f5236279..285bd80c 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command";
import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
index 370f6865..28e7feff 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -1,19 +1,19 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
-import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
+import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
import { updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import type {
+ AnimationPath,
AnimationInterpolation,
- AnimationPropertyPath,
AnimationValue,
-} from "@/types/animation";
+} from "@/lib/animation/types";
export class UpsertKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly propertyPath: AnimationPropertyPath;
+ private readonly propertyPath: AnimationPath;
private readonly time: number;
private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined;
@@ -30,7 +30,7 @@ export class UpsertKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
@@ -54,22 +54,28 @@ export class UpsertKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
- elementPredicate: (element) =>
- supportsAnimationProperty({
- element,
- propertyPath: this.propertyPath,
- }),
update: (element) => {
+ const target = resolveAnimationTarget({
+ element,
+ path: this.propertyPath,
+ });
+ if (!target) {
+ return element;
+ }
+
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
return {
...element,
- animations: upsertElementKeyframe({
+ animations: upsertPathKeyframe({
animations: element.animations,
propertyPath: this.propertyPath,
time: boundedTime,
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
+ valueKind: target.valueKind,
+ defaultInterpolation: target.defaultInterpolation,
+ numericRange: target.numericRange,
}),
};
},
diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts
new file mode 100644
index 00000000..acc47184
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts
@@ -0,0 +1,2 @@
+export { RemoveMaskCommand } from "./remove-mask";
+export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
diff --git a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts
new file mode 100644
index 00000000..ee09cbdc
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts
@@ -0,0 +1,64 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
+
+function removeMaskFromElement({
+ element,
+ maskId,
+}: {
+ element: MaskableElement;
+ maskId: string;
+}): MaskableElement {
+ const currentMasks = element.masks ?? [];
+ const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId);
+ return { ...element, masks: filteredMasks };
+}
+
+export class RemoveMaskCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) =>
+ removeMaskFromElement({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts
new file mode 100644
index 00000000..ac3c8c7c
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts
@@ -0,0 +1,75 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
+import type { Mask } from "@/lib/masks/types";
+import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
+
+export function toggleMaskInvertedOnElement({
+ element,
+ maskId,
+}: {
+ element: MaskableElement;
+ maskId: string;
+}): MaskableElement {
+ const currentMasks = element.masks ?? [];
+ const toggleMask = (mask: TMask): TMask => ({
+ ...mask,
+ params: {
+ ...mask.params,
+ inverted: !mask.params.inverted,
+ },
+ });
+ const updatedMasks = currentMasks.map((mask) =>
+ mask.id !== maskId ? mask : toggleMask(mask),
+ );
+
+ return { ...element, masks: updatedMasks };
+}
+
+export class ToggleMaskInvertedCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) =>
+ toggleMaskInvertedOnElement({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index 51e18c21..a36e8e93 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -4,7 +4,7 @@ import type {
TimelineTrack,
TimelineElement,
TrackType,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import {
buildEmptyTrack,
isMainTrack,
diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts
new file mode 100644
index 00000000..5daa59bf
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/retime/index.ts
@@ -0,0 +1 @@
+export { UpdateElementRetimeCommand } from "./update-element-retime";
diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
new file mode 100644
index 00000000..d1f859e6
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
@@ -0,0 +1,114 @@
+import { EditorCore } from "@/core";
+import { clampRetimeRate } from "@/constants/retime-constants";
+import { clampAnimationsToDuration } from "@/lib/animation";
+import { Command } from "@/lib/commands/base-command";
+import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";
+import { isRetimableElement, updateElementInTracks } from "@/lib/timeline";
+import type { RetimeConfig, TimelineTrack } from "@/lib/timeline";
+
+function getSourceDuration({
+ trimStart,
+ trimEnd,
+ duration,
+ sourceDuration,
+ retime,
+}: {
+ trimStart: number;
+ trimEnd: number;
+ duration: number;
+ sourceDuration?: number;
+ retime?: RetimeConfig;
+}): number {
+ if (typeof sourceDuration === "number") {
+ return sourceDuration;
+ }
+
+ return (
+ trimStart +
+ getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime,
+ }) +
+ trimEnd
+ );
+}
+
+export class UpdateElementRetimeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly retime: RetimeConfig | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ retime,
+ }: {
+ trackId: string;
+ elementId: string;
+ retime?: RetimeConfig;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.retime = retime;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isRetimableElement,
+ update: (element) => {
+ if (!isRetimableElement(element)) {
+ return element;
+ }
+
+ const nextRetime = this.retime
+ ? {
+ ...this.retime,
+ rate: clampRetimeRate({ rate: this.retime.rate }),
+ }
+ : undefined;
+ const sourceDuration = getSourceDuration({
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ duration: element.duration,
+ sourceDuration: element.sourceDuration,
+ retime: element.retime,
+ });
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - element.trimStart - element.trimEnd,
+ );
+ const nextDuration = getTimelineDurationForSourceSpan({
+ sourceSpan: visibleSourceSpan,
+ retime: nextRetime,
+ });
+
+ return {
+ ...element,
+ retime: nextRetime,
+ duration: nextDuration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: nextDuration,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts
index 37ccbb12..2acac683 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -1,9 +1,10 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
-import { rippleShiftElements } from "@/lib/timeline";
+import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { splitAnimationsAtTime } from "@/lib/animation";
+import { getSourceSpanAtClipTime } from "@/lib/retime";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -75,6 +76,18 @@ export class SplitElementsCommand extends Command {
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
+ const retimeRef = isRetimableElement(element)
+ ? element.retime
+ : undefined;
+ const leftSourceSpan = getSourceSpanAtClipTime({
+ clipTime: leftVisibleDuration,
+ retime: retimeRef,
+ });
+ const totalSourceSpan = getSourceSpanAtClipTime({
+ clipTime: element.duration,
+ retime: retimeRef,
+ });
+ const rightSourceSpan = totalSourceSpan - leftSourceSpan;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations,
splitTime: relativeTime,
@@ -83,63 +96,67 @@ export class SplitElementsCommand extends Command {
if (this.retainSide === "left") {
return [
- {
- ...element,
- duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightVisibleDuration,
- name: `${element.name} (left)`,
- animations: leftAnimations,
- },
- ];
- }
-
- if (this.retainSide === "right") {
- if (this.rippleEnabled && elementsToSplit.length === 1) {
- leftVisibleDurationForRipple = leftVisibleDuration;
- }
- const newId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: newId,
- });
- return [
- {
- ...element,
- id: newId,
- startTime: this.splitTime,
- duration: rightVisibleDuration,
- trimStart: element.trimStart + leftVisibleDuration,
- name: `${element.name} (right)`,
- animations: rightAnimations,
- },
- ];
- }
-
- // "both" - split into two pieces
- const secondElementId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: secondElementId,
- });
-
- return [
{
...element,
duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightVisibleDuration,
+ trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
- },
- {
- ...element,
- id: secondElementId,
- startTime: this.splitTime,
- duration: rightVisibleDuration,
- trimStart: element.trimStart + leftVisibleDuration,
- name: `${element.name} (right)`,
- animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
+ }
+
+ if (this.retainSide === "right") {
+ if (this.rippleEnabled && elementsToSplit.length === 1) {
+ leftVisibleDurationForRipple = leftVisibleDuration;
+ }
+ const newId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: newId,
+ });
+ return [
+ {
+ ...element,
+ id: newId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftSourceSpan,
+ name: `${element.name} (right)`,
+ animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ ];
+ }
+
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: secondElementId,
+ });
+
+ return [
+ {
+ ...element,
+ duration: leftVisibleDuration,
+ trimEnd: element.trimEnd + rightSourceSpan,
+ name: `${element.name} (left)`,
+ animations: leftAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ {
+ ...element,
+ id: secondElementId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftSourceSpan,
+ name: `${element.name} (right)`,
+ animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ ];
});
if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
index 1fa7421e..f416e7c5 100644
--- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
+++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
index daeb759a..24d51c15 100644
--- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
+++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { canElementBeHidden } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
index 3366548d..d88cf9cb 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
index e25bc0e3..75773ec2 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
index 7548638a..b2c77418 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
@@ -1,8 +1,9 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
-import { rippleShiftElements } from "@/lib/timeline";
+import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
+import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -48,7 +49,13 @@ export class UpdateElementTrimCommand extends Command {
if (!targetElement) return track;
const nextDuration = this.duration ?? targetElement.duration;
- const nextStartTime = this.startTime ?? targetElement.startTime;
+ const requestedStartTime = this.startTime ?? targetElement.startTime;
+ const nextStartTime = enforceMainTrackStart({
+ tracks: this.savedState ?? [],
+ targetTrackId: track.id,
+ requestedStartTime,
+ excludeElementId: this.elementId,
+ });
const oldEndTime = targetElement.startTime + targetElement.duration;
const newEndTime = nextStartTime + nextDuration;
@@ -60,6 +67,9 @@ export class UpdateElementTrimCommand extends Command {
trimEnd: this.trimEnd,
startTime: nextStartTime,
duration: nextDuration,
+ ...(isRetimableElement(targetElement)
+ ? { retime: targetElement.retime }
+ : {}),
animations: clampAnimationsToDuration({
animations: targetElement.animations,
duration: nextDuration,
@@ -68,7 +78,9 @@ export class UpdateElementTrimCommand extends Command {
if (this.rippleEnabled && Math.abs(shiftAmount) > 0) {
const shiftedOthers = rippleShiftElements({
- elements: track.elements.filter((element) => element.id !== this.elementId),
+ elements: track.elements.filter(
+ (element) => element.id !== this.elementId,
+ ),
afterTime: oldEndTime,
shiftAmount,
});
@@ -77,7 +89,8 @@ export class UpdateElementTrimCommand extends Command {
elements: track.elements.map((element) =>
element.id === this.elementId
? updatedElement
- : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element)
+ : (shiftedOthers.find((shifted) => shifted.id === element.id) ??
+ element),
),
} as typeof track;
}
@@ -85,7 +98,7 @@ export class UpdateElementTrimCommand extends Command {
return {
...track,
elements: track.elements.map((element) =>
- element.id === this.elementId ? updatedElement : element
+ element.id === this.elementId ? updatedElement : element,
),
} as typeof track;
});
diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts
index 643f8e4b..cf0c5cbc 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { updateElementInTracks } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts
index 95338888..dd4ceb64 100644
--- a/apps/web/src/lib/commands/timeline/track/add-track.ts
+++ b/apps/web/src/lib/commands/timeline/track/add-track.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TrackType, TimelineTrack } from "@/types/timeline";
+import type { TrackType, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts
index a32aacd7..35f29b1c 100644
--- a/apps/web/src/lib/commands/timeline/track/remove-track.ts
+++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline";
export class RemoveTrackCommand extends Command {
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
index 3fe526c7..9acf732a 100644
--- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
+++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { canTracktHaveAudio } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
index e81714ac..4bfd53c0 100644
--- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
+++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { canTrackBeHidden } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
index 2ba059c7..a4370cab 100644
--- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
+++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command {
diff --git a/apps/web/src/lib/drag-data.ts b/apps/web/src/lib/drag-data.ts
index 067b0a35..d847db95 100644
--- a/apps/web/src/lib/drag-data.ts
+++ b/apps/web/src/lib/drag-data.ts
@@ -1,4 +1,4 @@
-import type { TimelineDragData } from "@/types/drag";
+import type { TimelineDragData } from "@/lib/timeline/drag";
const MIME_TYPE = "application/x-timeline-drag";
let lastDragData: TimelineDragData | null = null;
diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl
index 2c015725..c9ad2eae 100644
--- a/apps/web/src/lib/effects/definitions/blur.frag.glsl
+++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl
@@ -3,6 +3,7 @@ precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_sigma;
+uniform float u_step;
uniform vec2 u_direction;
varying vec2 v_texCoord;
@@ -13,13 +14,12 @@ void main() {
vec4 color = vec4(0.0);
float totalWeight = 0.0;
- // step=1 texel — scaling step size instead causes discrete ghosting artifacts
for (int i = -30; i <= 30; i++) {
- float fi = float(i);
- float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
- color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight;
+ float pos = float(i) * u_step;
+ float weight = exp(-(pos * pos) / (2.0 * u_sigma * u_sigma));
+ color += texture2D(u_texture, v_texCoord + texelSize * u_direction * pos) * weight;
totalWeight += weight;
}
gl_FragColor = color / totalWeight;
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts
index 3819f82c..54934402 100644
--- a/apps/web/src/lib/effects/definitions/blur.ts
+++ b/apps/web/src/lib/effects/definitions/blur.ts
@@ -1,6 +1,72 @@
-import type { EffectDefinition } from "@/types/effects";
+import type { EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
import blurFragmentShader from "./blur.frag.glsl";
+const MAX_SINGLE_PASS_SIGMA = 10;
+const MAX_STEP = 4;
+const MAX_EFFECTIVE_SIGMA = MAX_SINGLE_PASS_SIGMA * MAX_STEP;
+const MAX_ITERATIONS = 8;
+
+/**
+ * Builds multi-pass gaussian blur passes for a given sigma.
+ * Shared by the blur effect and background blur — they each
+ * compute their own sigma from their own intensity scale.
+ */
+export function buildGaussianBlurPasses({
+ sigmaX,
+ sigmaY,
+}: {
+ sigmaX: number;
+ sigmaY: number;
+}): ResolvedEffectPass[] {
+ const maxSigma = Math.max(sigmaX, sigmaY);
+ if (maxSigma < 0.001) return [];
+
+ const iterations = Math.min(
+ MAX_ITERATIONS,
+ Math.max(
+ 1,
+ Math.ceil(
+ (maxSigma * maxSigma) /
+ (MAX_EFFECTIVE_SIGMA * MAX_EFFECTIVE_SIGMA),
+ ),
+ ),
+ );
+ const perPassSigmaX = sigmaX / Math.sqrt(iterations);
+ const perPassSigmaY = sigmaY / Math.sqrt(iterations);
+ const stepX = Math.max(1, perPassSigmaX / MAX_SINGLE_PASS_SIGMA);
+ const stepY = Math.max(1, perPassSigmaY / MAX_SINGLE_PASS_SIGMA);
+
+ const passes: ResolvedEffectPass[] = [];
+ for (let i = 0; i < iterations; i++) {
+ passes.push({
+ fragmentShader: blurFragmentShader,
+ uniforms: {
+ u_sigma: perPassSigmaX,
+ u_step: stepX,
+ u_direction: [1, 0],
+ },
+ });
+ passes.push({
+ fragmentShader: blurFragmentShader,
+ uniforms: {
+ u_sigma: perPassSigmaY,
+ u_step: stepY,
+ u_direction: [0, 1],
+ },
+ });
+ }
+ return passes;
+}
+
+function intensityToSigma(intensity: number, resolution: number, reference: number): number {
+ return (intensity / 5) * (resolution / reference);
+}
+
+function parseIntensity(effectParams: Record): number {
+ const raw = effectParams.intensity;
+ return typeof raw === "number" ? raw : Number.parseFloat(String(raw));
+}
+
export const blurEffectDefinition: EffectDefinition = {
type: "blur",
name: "Blur",
@@ -19,32 +85,29 @@ export const blurEffectDefinition: EffectDefinition = {
renderer: {
type: "webgl",
passes: [
- {
- fragmentShader: blurFragmentShader,
- uniforms: ({ effectParams, width }) => {
- const intensity =
- typeof effectParams.intensity === "number"
- ? effectParams.intensity
- : Number.parseFloat(String(effectParams.intensity));
- return {
- u_sigma: Math.max((intensity / 5) * (width / 1920), 0.001),
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams, width }) => ({
+ u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), width, 1920), 0.001),
+ u_step: 1,
u_direction: [1, 0],
- };
+ }),
},
- },
- {
- fragmentShader: blurFragmentShader,
- uniforms: ({ effectParams, height }) => {
- const intensity =
- typeof effectParams.intensity === "number"
- ? effectParams.intensity
- : Number.parseFloat(String(effectParams.intensity));
- return {
- u_sigma: Math.max((intensity / 5) * (height / 1080), 0.001),
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams, height }) => ({
+ u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), height, 1080), 0.001),
+ u_step: 1,
u_direction: [0, 1],
- };
+ }),
},
- },
],
+ buildPasses: ({ effectParams, width, height }) => {
+ const intensity = parseIntensity(effectParams);
+ return buildGaussianBlurPasses({
+ sigmaX: intensityToSigma(intensity, width, 1920),
+ sigmaY: intensityToSigma(intensity, height, 1080),
+ });
+ },
},
};
diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts
index 1742b1d7..edc8c5e2 100644
--- a/apps/web/src/lib/effects/definitions/index.ts
+++ b/apps/web/src/lib/effects/definitions/index.ts
@@ -1,13 +1,13 @@
-import { hasEffect, registerEffect } from "../registry";
+import { effectsRegistry } from "../registry";
import { blurEffectDefinition } from "./blur";
const defaultEffects = [blurEffectDefinition];
export function registerDefaultEffects(): void {
for (const definition of defaultEffects) {
- if (hasEffect({ effectType: definition.type })) {
+ if (effectsRegistry.has(definition.type)) {
continue;
}
- registerEffect({ definition });
+ effectsRegistry.register(definition.type, definition);
}
}
diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts
index 14d06d84..549811f5 100644
--- a/apps/web/src/lib/effects/index.ts
+++ b/apps/web/src/lib/effects/index.ts
@@ -1,29 +1,42 @@
import { generateUUID } from "@/utils/id";
-import { getEffect } from "./registry";
-import type { Effect, EffectParamValues } from "@/types/effects";
-import type { VisualElement } from "@/types/timeline";
+import { buildDefaultParamValues } from "@/lib/registry";
+import { effectsRegistry } from "./registry";
+import type { ParamValues } from "@/lib/params";
+import type { Effect, EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
+import { VISUAL_ELEMENT_TYPES } from "@/lib/timeline";
-export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
+export { effectsRegistry } from "./registry";
export { registerDefaultEffects } from "./definitions";
-export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [
- "video",
- "image",
- "text",
- "sticker",
-];
+export function resolveEffectPasses({
+ definition,
+ effectParams,
+ width,
+ height,
+}: {
+ definition: EffectDefinition;
+ effectParams: ParamValues;
+ width: number;
+ height: number;
+}): ResolvedEffectPass[] {
+ if (definition.renderer.buildPasses) {
+ return definition.renderer.buildPasses({ effectParams, width, height });
+ }
+ return definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({ effectParams, width, height }),
+ }));
+}
+
+export const EFFECT_TARGET_ELEMENT_TYPES = VISUAL_ELEMENT_TYPES;
export function buildDefaultEffectInstance({
effectType,
}: {
effectType: string;
}): Effect {
- const definition = getEffect({ effectType });
-
- const params: EffectParamValues = {};
- for (const paramDef of definition.params) {
- params[paramDef.key] = paramDef.default;
- }
+ const definition = effectsRegistry.get(effectType);
+ const params: ParamValues = buildDefaultParamValues(definition.params);
return {
id: generateUUID(),
diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts
index 568b8924..2b85007a 100644
--- a/apps/web/src/lib/effects/registry.ts
+++ b/apps/web/src/lib/effects/registry.ts
@@ -1,31 +1,10 @@
-import type { EffectDefinition } from "@/types/effects";
+import { DefinitionRegistry } from "@/lib/registry";
+import type { EffectDefinition } from "@/lib/effects/types";
-const effectDefinitions = new Map();
-
-export function registerEffect({
- definition,
-}: {
- definition: EffectDefinition;
-}): void {
- effectDefinitions.set(definition.type, definition);
-}
-
-export function hasEffect({ effectType }: { effectType: string }): boolean {
- return effectDefinitions.has(effectType);
-}
-
-export function getEffect({
- effectType,
-}: {
- effectType: string;
-}): EffectDefinition {
- const definition = effectDefinitions.get(effectType);
- if (!definition) {
- throw new Error(`Unknown effect type: ${effectType}`);
+export class EffectsRegistry extends DefinitionRegistry {
+ constructor() {
+ super("effect");
}
- return definition;
}
-export function getAllEffects(): EffectDefinition[] {
- return Array.from(effectDefinitions.values());
-}
+export const effectsRegistry = new EffectsRegistry();
diff --git a/apps/web/src/lib/effects/types.ts b/apps/web/src/lib/effects/types.ts
new file mode 100644
index 00000000..7f85389f
--- /dev/null
+++ b/apps/web/src/lib/effects/types.ts
@@ -0,0 +1,42 @@
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+
+export interface Effect {
+ id: string;
+ type: string;
+ params: ParamValues;
+ enabled: boolean;
+}
+
+export interface ResolvedEffectPass {
+ fragmentShader: string;
+ uniforms: Record;
+}
+
+export interface WebGLEffectPass {
+ fragmentShader: string;
+ uniforms(params: {
+ effectParams: ParamValues;
+ width: number;
+ height: number;
+ }): Record;
+}
+
+export interface WebGLEffectRenderer {
+ type: "webgl";
+ passes: WebGLEffectPass[];
+ buildPasses?: (params: {
+ effectParams: ParamValues;
+ width: number;
+ height: number;
+ }) => ResolvedEffectPass[];
+}
+
+export type EffectRenderer = WebGLEffectRenderer;
+
+export interface EffectDefinition {
+ type: string;
+ name: string;
+ keywords: string[];
+ params: ParamDefinition[];
+ renderer: EffectRenderer;
+}
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
index ae68144b..68d2bc7f 100644
--- a/apps/web/src/lib/export.ts
+++ b/apps/web/src/lib/export.ts
@@ -1,5 +1,36 @@
import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
-import type { ExportFormat } from "@/types/export";
+
+export const EXPORT_QUALITY_VALUES = [
+ "low",
+ "medium",
+ "high",
+ "very_high",
+] as const;
+
+export const EXPORT_FORMAT_VALUES = ["mp4", "webm"] as const;
+
+export type ExportFormat = (typeof EXPORT_FORMAT_VALUES)[number];
+export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number];
+
+export interface ExportOptions {
+ format: ExportFormat;
+ quality: ExportQuality;
+ fps?: number;
+ includeAudio?: boolean;
+}
+
+export interface ExportResult {
+ success: boolean;
+ buffer?: ArrayBuffer;
+ error?: string;
+ cancelled?: boolean;
+}
+
+export interface ExportState {
+ isExporting: boolean;
+ progress: number;
+ result: ExportResult | null;
+}
export function getExportMimeType({
format,
diff --git a/apps/web/src/lib/fonts/google-fonts.ts b/apps/web/src/lib/fonts/google-fonts.ts
index 7e55dffd..580ce406 100644
--- a/apps/web/src/lib/fonts/google-fonts.ts
+++ b/apps/web/src/lib/fonts/google-fonts.ts
@@ -1,14 +1,16 @@
-import type { FontAtlas } from "@/types/fonts";
+import type { FontAtlas } from "@/lib/fonts/types";
import { SYSTEM_FONTS } from "@/constants/font-constants";
const GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2";
+const FONT_ATLAS_PATH = "/fonts/font-atlas.json";
+const FONT_CHUNK_PATH_PREFIX = "/fonts/font-chunk-";
const fullLoaded = new Set();
let cachedAtlas: FontAtlas | null = null;
let atlasFetchPromise: Promise | null = null;
-function encodeFamily(family: string): string {
+function encodeGoogleFontsFamily(family: string): string {
return family.replace(/ /g, "+");
}
@@ -19,17 +21,19 @@ export function getCachedFontAtlas(): FontAtlas | null {
export function clearFontAtlasCache(): void {
cachedAtlas = null;
atlasFetchPromise = null;
+ fullLoaded.clear();
}
-async function fetchAtlas(): Promise {
- if (cachedAtlas) return cachedAtlas;
+export function loadFontAtlas(): Promise {
+ if (cachedAtlas) return Promise.resolve(cachedAtlas);
if (atlasFetchPromise) return atlasFetchPromise;
- atlasFetchPromise = fetch("/fonts/font-atlas.json")
+ atlasFetchPromise = fetch(FONT_ATLAS_PATH)
.then(async (response) => {
if (!response.ok) return null;
const data: FontAtlas = await response.json();
cachedAtlas = data;
+ preloadChunkImages({ atlas: data });
return data;
})
.catch(() => null);
@@ -42,18 +46,12 @@ function preloadChunkImages({ atlas }: { atlas: FontAtlas }): void {
...Object.values(atlas.fonts).map((entry) => entry.ch),
);
for (let i = 0; i <= maxChunk; i++) {
+ // hint browser to preload chunk images without blocking
const img = new Image();
- img.src = `/fonts/font-chunk-${i}.avif`;
+ img.src = `${FONT_CHUNK_PATH_PREFIX}${i}.avif`;
}
}
-export function prefetchFontAtlas(): Promise {
- return fetchAtlas().then((atlas) => {
- if (atlas) preloadChunkImages({ atlas });
- return atlas;
- });
-}
-
export async function loadFullFont({
family,
weights = [400, 700],
@@ -63,7 +61,7 @@ export async function loadFullFont({
}): Promise