Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8c95f04f | ||
|
|
0f123283b3 | ||
|
|
b33ec5e2d7 | ||
|
|
0a0dd088c3 | ||
|
|
2669b380a3 | ||
|
|
0935dac70a | ||
|
|
e2147bec63 | ||
|
|
3d2d0a4dbc | ||
|
|
1591f7dfcb | ||
|
|
c3e4c86b33 | ||
|
|
942a7e599a | ||
|
|
776ed954f2 | ||
|
|
f1a453b9b2 | ||
|
|
e4263d4597 | ||
|
|
2fb5b3b574 | ||
|
|
9a5d94a1c8 | ||
|
|
119c3acb18 | ||
|
|
881acdb26f | ||
|
|
4a308fde12 | ||
|
|
fc8a4db8f1 | ||
|
|
ac4f82484b | ||
|
|
e9f0fda397 | ||
|
|
4655e71ca5 | ||
|
|
e82332647a | ||
|
|
9fb91dd17b | ||
|
|
c8cf052fc9 | ||
|
|
61d89831bb | ||
|
|
9f6ef0f582 | ||
|
|
ea68300634 | ||
|
|
446e3a35fc | ||
|
|
c35a33203b | ||
|
|
dd84edaf41 | ||
|
|
9d71f509b8 | ||
|
|
9687157aba | ||
|
|
e2075f15e9 | ||
|
|
d182854270 | ||
|
|
7a8d0f449a | ||
|
|
0727b61de7 | ||
|
|
56988e86e2 | ||
|
|
965d3e5f4c | ||
|
|
5f6576768c | ||
|
|
6c086be1b6 | ||
|
|
b52d27bf56 | ||
|
|
e3c922d032 | ||
|
|
16dea49fa8 | ||
|
|
575a339550 | ||
|
|
e5fa783b59 | ||
|
|
b5cc7777d7 | ||
|
|
e72fb8252c | ||
|
|
5e8bb99e96 | ||
|
|
1b08618831 | ||
|
|
144e34318e | ||
|
|
6035719252 | ||
|
|
2af33894e9 | ||
|
|
63fd87612e | ||
|
|
4b79909116 | ||
|
|
0a6895e89f | ||
|
|
36a0a304d5 | ||
|
|
4f68df1db8 | ||
|
|
7422e16b1e | ||
|
|
151a3b2902 | ||
|
|
066832a3bd | ||
|
|
118158b8ee | ||
|
|
948e2b1e4a | ||
|
|
c48243360b | ||
|
|
7833dee014 | ||
|
|
991727d1c5 | ||
|
|
8e1e0e33e3 | ||
|
|
e02ef0d2c0 | ||
|
|
fc7c1d28e5 | ||
|
|
2553442a7d | ||
|
|
b3247a6a97 | ||
|
|
124f2da992 | ||
|
|
9343453365 | ||
|
|
1802725581 | ||
|
|
885d66c4a4 | ||
|
|
555b199e03 | ||
|
|
371f79a35f | ||
|
|
64bc261c20 | ||
|
|
21e9f38be6 | ||
|
|
a4fa260727 | ||
|
|
546bc7352c | ||
|
|
9540a8c0a9 | ||
|
|
dcf35a6ede | ||
|
|
c8ebef026b | ||
|
|
7226632fc4 | ||
|
|
60d3dfaef5 | ||
|
|
2ccead5fb9 | ||
|
|
cbfc242308 | ||
|
|
f0779c96a3 | ||
|
|
9eb362012b | ||
|
|
6d44dafd96 | ||
|
|
e6e3abb88c | ||
|
|
843c130834 | ||
|
|
f384338765 | ||
|
|
0e082fff9c | ||
|
|
6ca24c3411 | ||
|
|
31bc733415 | ||
|
|
451bb203b7 | ||
|
|
0e85679b14 | ||
|
|
57fdad0646 | ||
|
|
71bb09c82e | ||
|
|
4ab8f3d1f1 | ||
|
|
4bac15cb44 | ||
|
|
d76f38fb35 | ||
|
|
236ca4da29 | ||
|
|
cf8d211eb2 | ||
|
|
4ecd18086c | ||
|
|
a2b9eea90a | ||
|
|
4b3afcf535 | ||
|
|
185969a9d1 | ||
|
|
5f20820735 | ||
|
|
397a943426 | ||
|
|
83d3e7b6b8 | ||
|
|
5573c9f427 | ||
|
|
92d2a41296 | ||
|
|
9bc2c78b4d | ||
|
|
87735c2716 | ||
|
|
91c9de2561 | ||
|
|
9df9264d25 | ||
|
|
c6d33aa82a | ||
|
|
85f2388041 | ||
|
|
44cf97c7a1 | ||
|
|
bd50b193a1 | ||
|
|
491db0ab2e | ||
|
|
518fe4ca15 | ||
|
|
647705fb58 | ||
|
|
59d786bfda | ||
|
|
19476da5cc | ||
|
|
cdff1a9b5d | ||
|
|
fac4af40c7 | ||
|
|
d9177b4a44 | ||
|
|
4d7e2a2d85 | ||
|
|
8e94dcbc2c | ||
|
|
05f4e74de6 | ||
|
|
a89198ccdc | ||
|
|
170dd2efd2 | ||
|
|
d4704d554d | ||
|
|
485d2fd098 | ||
|
|
d5dbcb3a7a | ||
|
|
e1760020ac | ||
|
|
c8a4becaf0 | ||
|
|
e6db74f183 | ||
|
|
271a3ee443 | ||
|
|
cef987cd33 | ||
|
|
95b4df0ae4 | ||
|
|
0d27f4fc36 | ||
|
|
23aced6007 | ||
|
|
db9cf960f8 | ||
|
|
afbd0740a3 | ||
|
|
f30e2d654e | ||
|
|
f2c6d8ff0f | ||
|
|
9821e926d9 | ||
|
|
a2ca0799d4 |
@@ -0,0 +1,14 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,18 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Search existing issues
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: e.g., When I click submit, nothing happens...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
placeholder: e.g., The form should submit and show a success message
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
placeholder: Drag and drop images here or paste them
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: os-type
|
||||
attributes:
|
||||
label: OS
|
||||
description: Operating system
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- iOS
|
||||
- Android
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: OS Version
|
||||
description: Please specify your OS version
|
||||
placeholder: e.g., Windows 11, macOS Sonoma, Ubuntu 22.04
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: os-other
|
||||
attributes:
|
||||
label: Other OS
|
||||
description: If you selected "Other" for OS, please specify your operating system
|
||||
placeholder: e.g., FreeBSD, Solaris
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: What browser are you using?
|
||||
options:
|
||||
- Chrome
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser-version
|
||||
attributes:
|
||||
label: Browser Version
|
||||
description: Please specify your browser version
|
||||
placeholder: e.g., 120.0, 121.0.1
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser-other
|
||||
attributes:
|
||||
label: Other Browser
|
||||
description: If you selected "Other" for Browser, please specify your browser
|
||||
placeholder: e.g., Brave, Vivaldi, Opera
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: device-type
|
||||
attributes:
|
||||
label: Device Type
|
||||
description: Device category
|
||||
options:
|
||||
- Desktop
|
||||
- Laptop
|
||||
- Tablet
|
||||
- Mobile
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: device-other
|
||||
attributes:
|
||||
label: Other Device
|
||||
description: If you selected "Other" for Device Type, please specify your device
|
||||
placeholder: e.g., Smart TV, IoT device
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
placeholder: Links, references, or any additional information
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "feature-request"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Search existing issues
|
||||
description: Please search to see if an issue already exists for this feature request.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is.
|
||||
placeholder: e.g., I'm always frustrated when I have to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
placeholder: Describe the feature or change you're proposing
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
placeholder: Have you considered any workarounds or alternative approaches?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
placeholder: Links, mockups, or any additional information
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,67 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
typecheck:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx vite build
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
# Install Electron system dependencies not covered by Playwright's chromium deps
|
||||
- run: npx electron . --version || sudo apt-get install -y libgbm-dev
|
||||
- run: npm run build-vite
|
||||
# xvfb provides a virtual display; Electron needs one on Linux even with show:false
|
||||
- run: xvfb-run --auto-servernum npm run test:e2e
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
@@ -9,6 +9,7 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
@@ -24,4 +25,8 @@ dist-ssr
|
||||
*.sw?
|
||||
release/**
|
||||
*.kiro/
|
||||
# npx electron-builder --mac --win
|
||||
# npx electron-builder --mac --win
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report/
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -1,94 +1,99 @@
|
||||
<p align="center">
|
||||
<img src="openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# <p align="center">OpenScreen</p>
|
||||
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
|
||||
|
||||
|
||||
|
||||
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
|
||||
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
|
||||
|
||||
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
|
||||
|
||||
|
||||
|
||||
**⚠️ DISCLAIMER: This is very much in beta and might be buggy here and there (but hope you have a good experience!).**
|
||||
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="preview.png" alt="OpenScreen App Preview" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="preview2.png" alt="OpenScreen App Preview 2" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
|
||||
|
||||
</p>
|
||||
</p>
|
||||
|
||||
## Core Features
|
||||
- Record your whole screen or specific apps
|
||||
- Add manual zooms (customizable depth levels)
|
||||
- Customize the duration and position of zooms however you please
|
||||
- Crop video recordings to hide parts
|
||||
- Choose between wallpapers, solid colors, gradients or your own picture for your background
|
||||
- Motion blur for smoother pan and zoom effects
|
||||
- Add annotations (text, arrows, images)
|
||||
- Trim sections of the clip
|
||||
- Export in different aspect ratios and resolutions
|
||||
|
||||
## Installation
|
||||
|
||||
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
|
||||
|
||||
### macOS
|
||||
|
||||
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/Openscreen.app
|
||||
```
|
||||
|
||||
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
|
||||
|
||||
### Linux
|
||||
|
||||
Download the `.AppImage` file from the releases page. Make it executable and run:
|
||||
|
||||
```bash
|
||||
chmod +x Openscreen-Linux-*.AppImage
|
||||
./Openscreen-Linux-*.AppImage
|
||||
```
|
||||
|
||||
You may need to grant screen recording permissions depending on your desktop environment.
|
||||
|
||||
## Built with
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- PixiJS
|
||||
- dnd-timeline
|
||||
|
||||
---
|
||||
|
||||
|
||||
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
||||
|
||||
> [!WARNING]
|
||||
> This is very much in beta and might be buggy here and there (but hope you have a good experience!).
|
||||
|
||||
<p align="center">
|
||||
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# <p align="center">OpenScreen</p>
|
||||
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
|
||||
|
||||
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
|
||||
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
|
||||
|
||||
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
|
||||
|
||||
<p align="center">
|
||||
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
|
||||
</p>
|
||||
|
||||
## Core Features
|
||||
- Record your whole screen or specific windows.
|
||||
- Add Automatic zooms or manual zooms (customizable depth levels).
|
||||
- Record microphone audio and system audio capture.
|
||||
- Customize the duration and position of zooms however you please.
|
||||
- Crop video recordings to hide parts.
|
||||
- Choose between wallpapers, solid colors, gradients or a custom background.
|
||||
- Motion blur for smoother pan and zoom effects.
|
||||
- Add annotations (text, arrows, images).
|
||||
- Trim sections of the clip.
|
||||
- Customize speed at different segments.
|
||||
- Export in different aspect ratios and resolutions.
|
||||
|
||||
## Installation
|
||||
|
||||
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
|
||||
|
||||
### macOS
|
||||
|
||||
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/Openscreen.app
|
||||
```
|
||||
|
||||
Note: Give your terminal Full Disk Access in **System Settings > Privacy & Security** to grant you access and then run the above command.
|
||||
|
||||
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
|
||||
|
||||
### Linux
|
||||
|
||||
Download the `.AppImage` file from the releases page. Make it executable and run:
|
||||
|
||||
```bash
|
||||
chmod +x Openscreen-Linux-*.AppImage
|
||||
./Openscreen-Linux-*.AppImage
|
||||
```
|
||||
|
||||
You may need to grant screen recording permissions depending on your desktop environment.
|
||||
|
||||
**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox:
|
||||
```bash
|
||||
./Openscreen-Linux-*.AppImage --no-sandbox
|
||||
```
|
||||
|
||||
### Limitations
|
||||
|
||||
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
|
||||
|
||||
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still work).
|
||||
- **Windows**: Works out of the box.
|
||||
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works).
|
||||
|
||||
## Built with
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- PixiJS
|
||||
- dnd-timeline
|
||||
|
||||
---
|
||||
|
||||
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
|
||||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||
"files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"formatWithErrors": true,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 100,
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": false,
|
||||
"complexity": {
|
||||
"noAdjacentSpacesInRegex": "error",
|
||||
"noBannedTypes": "error",
|
||||
"noExtraBooleanCast": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessEscapeInRegex": "error",
|
||||
"noUselessThisAlias": "error",
|
||||
"noUselessTypeConstraint": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "error",
|
||||
"noConstantCondition": "error",
|
||||
"noEmptyCharacterClassInRegex": "error",
|
||||
"noEmptyPattern": "error",
|
||||
"noGlobalObjectCalls": "error",
|
||||
"noInnerDeclarations": "error",
|
||||
"noInvalidConstructorSuper": "error",
|
||||
"noNonoctalDecimalEscape": "error",
|
||||
"noPrecisionLoss": "error",
|
||||
"noSelfAssign": "error",
|
||||
"noSetterReturn": "error",
|
||||
"noSwitchDeclarations": "error",
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnreachable": "error",
|
||||
"noUnreachableSuper": "error",
|
||||
"noUnsafeFinally": "error",
|
||||
"noUnsafeOptionalChaining": "error",
|
||||
"noUnusedLabels": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"useExhaustiveDependencies": "warn",
|
||||
"useHookAtTopLevel": "error",
|
||||
"useIsNan": "error",
|
||||
"useValidForDirection": "error",
|
||||
"useValidTypeof": "error",
|
||||
"useYield": "error"
|
||||
},
|
||||
"style": {
|
||||
"noNamespace": "off",
|
||||
"useArrayLiterals": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useComponentExportOnlyModules": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAssignInExpressions": "error",
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noCatchAssign": "error",
|
||||
"noClassAssign": "error",
|
||||
"noCompareNegZero": "error",
|
||||
"noControlCharactersInRegex": "error",
|
||||
"noDebugger": "error",
|
||||
"noDuplicateCase": "error",
|
||||
"noDuplicateClassMembers": "error",
|
||||
"noDuplicateElseIf": "error",
|
||||
"noDuplicateObjectKeys": "error",
|
||||
"noDuplicateParameters": "error",
|
||||
"noEmptyBlockStatements": "warn",
|
||||
"noExplicitAny": "warn",
|
||||
"noExtraNonNullAssertion": "error",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noFunctionAssign": "error",
|
||||
"noGlobalAssign": "error",
|
||||
"noImportAssign": "error",
|
||||
"noIrregularWhitespace": "error",
|
||||
"noMisleadingCharacterClass": "error",
|
||||
"noMisleadingInstantiator": "error",
|
||||
"noNonNullAssertedOptionalChain": "error",
|
||||
"noPrototypeBuiltins": "error",
|
||||
"noRedeclare": "error",
|
||||
"noShadowRestrictedNames": "error",
|
||||
"noSparseArray": "error",
|
||||
"noTsIgnore": "error",
|
||||
"noUnsafeDeclarationMerging": "error",
|
||||
"noUnsafeNegation": "error",
|
||||
"noUselessRegexBackrefs": "error",
|
||||
"noWith": "error",
|
||||
"useGetterReturn": "error"
|
||||
}
|
||||
},
|
||||
"includes": ["**", "**/dist", "**/.eslintrc.cjs", "!**/*.css"]
|
||||
},
|
||||
"javascript": { "formatter": { "quoteStyle": "double" } },
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": { "noArguments": "error" },
|
||||
"correctness": {
|
||||
"noConstAssign": "off",
|
||||
"noGlobalObjectCalls": "off",
|
||||
"noInvalidBuiltinInstantiation": "off",
|
||||
"noInvalidConstructorSuper": "off",
|
||||
"noSetterReturn": "off",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noUnreachable": "off",
|
||||
"noUnreachableSuper": "off"
|
||||
},
|
||||
"style": { "useConst": "error" },
|
||||
"suspicious": {
|
||||
"noDuplicateClassMembers": "off",
|
||||
"noDuplicateObjectKeys": "off",
|
||||
"noDuplicateParameters": "off",
|
||||
"noFunctionAssign": "off",
|
||||
"noImportAssign": "off",
|
||||
"noRedeclare": "off",
|
||||
"noUnsafeNegation": "off",
|
||||
"noVar": "error",
|
||||
"useGetterReturn": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": { "source": { "organizeImports": "on" } }
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.cjs",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.cjs",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
import { ipcMain as s, screen as F, BrowserWindow as R, desktopCapturer as L, shell as C, app as d, dialog as E, nativeImage as U, Tray as M, Menu as A } from "electron";
|
||||
import { fileURLToPath as j } from "node:url";
|
||||
import o from "node:path";
|
||||
import P from "node:fs/promises";
|
||||
const _ = o.dirname(j(import.meta.url)), z = o.join(_, ".."), w = process.env.VITE_DEV_SERVER_URL, S = o.join(z, "dist");
|
||||
let m = null;
|
||||
s.on("hud-overlay-hide", () => {
|
||||
m && !m.isDestroyed() && m.minimize();
|
||||
});
|
||||
function H() {
|
||||
const n = F.getPrimaryDisplay(), { workArea: t } = n, c = 500, u = 100, y = Math.floor(t.x + (t.width - c) / 2), p = Math.floor(t.y + t.height - u - 5), e = new R({
|
||||
width: c,
|
||||
height: u,
|
||||
minWidth: 500,
|
||||
maxWidth: 500,
|
||||
minHeight: 100,
|
||||
maxHeight: 100,
|
||||
x: y,
|
||||
y: p,
|
||||
frame: !1,
|
||||
transparent: !0,
|
||||
resizable: !1,
|
||||
alwaysOnTop: !0,
|
||||
skipTaskbar: !0,
|
||||
hasShadow: !1,
|
||||
webPreferences: {
|
||||
preload: o.join(_, "preload.mjs"),
|
||||
nodeIntegration: !1,
|
||||
contextIsolation: !0,
|
||||
backgroundThrottling: !1
|
||||
}
|
||||
});
|
||||
return e.webContents.on("did-finish-load", () => {
|
||||
e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
|
||||
}), m = e, e.on("closed", () => {
|
||||
m === e && (m = null);
|
||||
}), w ? e.loadURL(w + "?windowType=hud-overlay") : e.loadFile(o.join(S, "index.html"), {
|
||||
query: { windowType: "hud-overlay" }
|
||||
}), e;
|
||||
}
|
||||
function q() {
|
||||
const n = process.platform === "darwin", t = new R({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
...n && {
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 }
|
||||
},
|
||||
transparent: !1,
|
||||
resizable: !0,
|
||||
alwaysOnTop: !1,
|
||||
skipTaskbar: !1,
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
webPreferences: {
|
||||
preload: o.join(_, "preload.mjs"),
|
||||
nodeIntegration: !1,
|
||||
contextIsolation: !0,
|
||||
webSecurity: !1,
|
||||
backgroundThrottling: !1
|
||||
}
|
||||
});
|
||||
return t.maximize(), t.webContents.on("did-finish-load", () => {
|
||||
t == null || t.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
|
||||
}), w ? t.loadURL(w + "?windowType=editor") : t.loadFile(o.join(S, "index.html"), {
|
||||
query: { windowType: "editor" }
|
||||
}), t;
|
||||
}
|
||||
function B() {
|
||||
const { width: n, height: t } = F.getPrimaryDisplay().workAreaSize, c = new R({
|
||||
width: 620,
|
||||
height: 420,
|
||||
minHeight: 350,
|
||||
maxHeight: 500,
|
||||
x: Math.round((n - 620) / 2),
|
||||
y: Math.round((t - 420) / 2),
|
||||
frame: !1,
|
||||
resizable: !1,
|
||||
alwaysOnTop: !0,
|
||||
transparent: !0,
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
preload: o.join(_, "preload.mjs"),
|
||||
nodeIntegration: !1,
|
||||
contextIsolation: !0
|
||||
}
|
||||
});
|
||||
return w ? c.loadURL(w + "?windowType=source-selector") : c.loadFile(o.join(S, "index.html"), {
|
||||
query: { windowType: "source-selector" }
|
||||
}), c;
|
||||
}
|
||||
let T = null;
|
||||
function N(n, t, c, u, y) {
|
||||
s.handle("get-sources", async (e, a) => (await L.getSources(a)).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
display_id: r.display_id,
|
||||
thumbnail: r.thumbnail ? r.thumbnail.toDataURL() : null,
|
||||
appIcon: r.appIcon ? r.appIcon.toDataURL() : null
|
||||
}))), s.handle("select-source", (e, a) => {
|
||||
T = a;
|
||||
const l = u();
|
||||
return l && l.close(), T;
|
||||
}), s.handle("get-selected-source", () => T), s.handle("open-source-selector", () => {
|
||||
const e = u();
|
||||
if (e) {
|
||||
e.focus();
|
||||
return;
|
||||
}
|
||||
t();
|
||||
}), s.handle("switch-to-editor", () => {
|
||||
const e = c();
|
||||
e && e.close(), n();
|
||||
}), s.handle("store-recorded-video", async (e, a, l) => {
|
||||
try {
|
||||
const r = o.join(h, l);
|
||||
return await P.writeFile(r, Buffer.from(a)), p = r, {
|
||||
success: !0,
|
||||
path: r,
|
||||
message: "Video stored successfully"
|
||||
};
|
||||
} catch (r) {
|
||||
return console.error("Failed to store video:", r), {
|
||||
success: !1,
|
||||
message: "Failed to store video",
|
||||
error: String(r)
|
||||
};
|
||||
}
|
||||
}), s.handle("get-recorded-video-path", async () => {
|
||||
try {
|
||||
const a = (await P.readdir(h)).filter((I) => I.endsWith(".webm"));
|
||||
if (a.length === 0)
|
||||
return { success: !1, message: "No recorded video found" };
|
||||
const l = a.sort().reverse()[0];
|
||||
return { success: !0, path: o.join(h, l) };
|
||||
} catch (e) {
|
||||
return console.error("Failed to get video path:", e), { success: !1, message: "Failed to get video path", error: String(e) };
|
||||
}
|
||||
}), s.handle("set-recording-state", (e, a) => {
|
||||
y && y(a, (T || { name: "Screen" }).name);
|
||||
}), s.handle("open-external-url", async (e, a) => {
|
||||
try {
|
||||
return await C.openExternal(a), { success: !0 };
|
||||
} catch (l) {
|
||||
return console.error("Failed to open URL:", l), { success: !1, error: String(l) };
|
||||
}
|
||||
}), s.handle("get-asset-base-path", () => {
|
||||
try {
|
||||
return d.isPackaged ? o.join(process.resourcesPath, "assets") : o.join(d.getAppPath(), "public", "assets");
|
||||
} catch (e) {
|
||||
return console.error("Failed to resolve asset base path:", e), null;
|
||||
}
|
||||
}), s.handle("save-exported-video", async (e, a, l) => {
|
||||
try {
|
||||
const r = l.toLowerCase().endsWith(".gif"), I = r ? [{ name: "GIF Image", extensions: ["gif"] }] : [{ name: "MP4 Video", extensions: ["mp4"] }], v = await E.showSaveDialog({
|
||||
title: r ? "Save Exported GIF" : "Save Exported Video",
|
||||
defaultPath: o.join(d.getPath("downloads"), l),
|
||||
filters: I,
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"]
|
||||
});
|
||||
return v.canceled || !v.filePath ? {
|
||||
success: !1,
|
||||
cancelled: !0,
|
||||
message: "Export cancelled"
|
||||
} : (await P.writeFile(v.filePath, Buffer.from(a)), {
|
||||
success: !0,
|
||||
path: v.filePath,
|
||||
message: "Video exported successfully"
|
||||
});
|
||||
} catch (r) {
|
||||
return console.error("Failed to save exported video:", r), {
|
||||
success: !1,
|
||||
message: "Failed to save exported video",
|
||||
error: String(r)
|
||||
};
|
||||
}
|
||||
}), s.handle("open-video-file-picker", async () => {
|
||||
try {
|
||||
const e = await E.showOpenDialog({
|
||||
title: "Select Video File",
|
||||
defaultPath: h,
|
||||
filters: [
|
||||
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
|
||||
{ name: "All Files", extensions: ["*"] }
|
||||
],
|
||||
properties: ["openFile"]
|
||||
});
|
||||
return e.canceled || e.filePaths.length === 0 ? { success: !1, cancelled: !0 } : {
|
||||
success: !0,
|
||||
path: e.filePaths[0]
|
||||
};
|
||||
} catch (e) {
|
||||
return console.error("Failed to open file picker:", e), {
|
||||
success: !1,
|
||||
message: "Failed to open file picker",
|
||||
error: String(e)
|
||||
};
|
||||
}
|
||||
});
|
||||
let p = null;
|
||||
s.handle("set-current-video-path", (e, a) => (p = a, { success: !0 })), s.handle("get-current-video-path", () => p ? { success: !0, path: p } : { success: !1 }), s.handle("clear-current-video-path", () => (p = null, { success: !0 })), s.handle("get-platform", () => process.platform);
|
||||
}
|
||||
const G = o.dirname(j(import.meta.url)), h = o.join(d.getPath("userData"), "recordings");
|
||||
async function $() {
|
||||
try {
|
||||
await P.mkdir(h, { recursive: !0 }), console.log("RECORDINGS_DIR:", h), console.log("User Data Path:", d.getPath("userData"));
|
||||
} catch (n) {
|
||||
console.error("Failed to create recordings directory:", n);
|
||||
}
|
||||
}
|
||||
process.env.APP_ROOT = o.join(G, "..");
|
||||
const Q = process.env.VITE_DEV_SERVER_URL, re = o.join(process.env.APP_ROOT, "dist-electron"), O = o.join(process.env.APP_ROOT, "dist");
|
||||
process.env.VITE_PUBLIC = Q ? o.join(process.env.APP_ROOT, "public") : O;
|
||||
let i = null, g = null, f = null, V = "";
|
||||
const W = k("openscreen.png"), J = k("rec-button.png");
|
||||
function b() {
|
||||
i = H();
|
||||
}
|
||||
function D() {
|
||||
f = new M(W);
|
||||
}
|
||||
function k(n) {
|
||||
return U.createFromPath(o.join(process.env.VITE_PUBLIC || O, n)).resize({
|
||||
width: 24,
|
||||
height: 24,
|
||||
quality: "best"
|
||||
});
|
||||
}
|
||||
function x(n = !1) {
|
||||
if (!f) return;
|
||||
const t = n ? J : W, c = n ? `Recording: ${V}` : "OpenScreen", u = n ? [
|
||||
{
|
||||
label: "Stop Recording",
|
||||
click: () => {
|
||||
i && !i.isDestroyed() && i.webContents.send("stop-recording-from-tray");
|
||||
}
|
||||
}
|
||||
] : [
|
||||
{
|
||||
label: "Open",
|
||||
click: () => {
|
||||
i && !i.isDestroyed() ? i.isMinimized() && i.restore() : b();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
d.quit();
|
||||
}
|
||||
}
|
||||
];
|
||||
f.setImage(t), f.setToolTip(c), f.setContextMenu(A.buildFromTemplate(u));
|
||||
}
|
||||
function K() {
|
||||
i && (i.close(), i = null), i = q();
|
||||
}
|
||||
function X() {
|
||||
return g = B(), g.on("closed", () => {
|
||||
g = null;
|
||||
}), g;
|
||||
}
|
||||
d.on("window-all-closed", () => {
|
||||
});
|
||||
d.on("activate", () => {
|
||||
R.getAllWindows().length === 0 && b();
|
||||
});
|
||||
d.whenReady().then(async () => {
|
||||
const { ipcMain: n } = await import("electron");
|
||||
n.on("hud-overlay-close", () => {
|
||||
d.quit();
|
||||
}), D(), x(), await $(), N(
|
||||
K,
|
||||
X,
|
||||
() => i,
|
||||
() => g,
|
||||
(t, c) => {
|
||||
V = c, f || D(), x(t), t || i && i.restore();
|
||||
}
|
||||
), b();
|
||||
});
|
||||
export {
|
||||
re as MAIN_DIST,
|
||||
h as RECORDINGS_DIR,
|
||||
O as RENDERER_DIST,
|
||||
Q as VITE_DEV_SERVER_URL
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
"use strict";const e=require("electron");e.contextBridge.exposeInMainWorld("electronAPI",{hudOverlayHide:()=>{e.ipcRenderer.send("hud-overlay-hide")},hudOverlayClose:()=>{e.ipcRenderer.send("hud-overlay-close")},getAssetBasePath:async()=>await e.ipcRenderer.invoke("get-asset-base-path"),getSources:async r=>await e.ipcRenderer.invoke("get-sources",r),switchToEditor:()=>e.ipcRenderer.invoke("switch-to-editor"),openSourceSelector:()=>e.ipcRenderer.invoke("open-source-selector"),selectSource:r=>e.ipcRenderer.invoke("select-source",r),getSelectedSource:()=>e.ipcRenderer.invoke("get-selected-source"),storeRecordedVideo:(r,t)=>e.ipcRenderer.invoke("store-recorded-video",r,t),getRecordedVideoPath:()=>e.ipcRenderer.invoke("get-recorded-video-path"),setRecordingState:r=>e.ipcRenderer.invoke("set-recording-state",r),onStopRecordingFromTray:r=>{const t=()=>r();return e.ipcRenderer.on("stop-recording-from-tray",t),()=>e.ipcRenderer.removeListener("stop-recording-from-tray",t)},openExternalUrl:r=>e.ipcRenderer.invoke("open-external-url",r),saveExportedVideo:(r,t)=>e.ipcRenderer.invoke("save-exported-video",r,t),openVideoFilePicker:()=>e.ipcRenderer.invoke("open-video-file-picker"),setCurrentVideoPath:r=>e.ipcRenderer.invoke("set-current-video-path",r),getCurrentVideoPath:()=>e.ipcRenderer.invoke("get-current-video-path"),clearCurrentVideoPath:()=>e.ipcRenderer.invoke("clear-current-video-path"),getPlatform:()=>e.ipcRenderer.invoke("get-platform")});
|
||||
@@ -28,6 +28,7 @@
|
||||
],
|
||||
|
||||
"mac": {
|
||||
"hardenedRuntime": false,
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
@@ -35,7 +36,14 @@
|
||||
}
|
||||
],
|
||||
"icon": "icons/icons/mac/icon.icns",
|
||||
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}"
|
||||
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
||||
"extendInfo": {
|
||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true,
|
||||
"com.apple.security.device.audio-input": true
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
@@ -45,10 +53,14 @@
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}",
|
||||
"category": "AudioVideo"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico"
|
||||
}
|
||||
}
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,154 @@
|
||||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* The built directory structure
|
||||
*
|
||||
* ```tree
|
||||
* ├─┬─┬ dist
|
||||
* │ │ └── index.html
|
||||
* │ │
|
||||
* │ ├─┬ dist-electron
|
||||
* │ │ ├── main.js
|
||||
* │ │ └── preload.js
|
||||
* │
|
||||
* ```
|
||||
*/
|
||||
APP_ROOT: string
|
||||
/** /dist/ or /public/ */
|
||||
VITE_PUBLIC: string
|
||||
}
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>
|
||||
switchToEditor: () => Promise<void>
|
||||
openSourceSelector: () => Promise<void>
|
||||
selectSource: (source: any) => Promise<any>
|
||||
getSelectedSource: () => Promise<any>
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
|
||||
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>
|
||||
setRecordingState: (recording: boolean) => Promise<void>
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean }>
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; cancelled?: boolean }>
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>
|
||||
getPlatform: () => Promise<string>
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
interface ProcessedDesktopSource {
|
||||
id: string
|
||||
name: string
|
||||
display_id: string
|
||||
thumbnail: string | null
|
||||
appIcon: string | null
|
||||
}
|
||||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* The built directory structure
|
||||
*
|
||||
* ```tree
|
||||
* ├─┬─┬ dist
|
||||
* │ │ └── index.html
|
||||
* │ │
|
||||
* │ ├─┬ dist-electron
|
||||
* │ │ ├── main.js
|
||||
* │ │ └── preload.js
|
||||
* │
|
||||
* ```
|
||||
*/
|
||||
APP_ROOT: string;
|
||||
/** /dist/ or /public/ */
|
||||
VITE_PUBLIC: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||
requestCameraAccess: () => Promise<{
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getAssetBasePath: () => Promise<string | null>;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
storeRecordedSession: (
|
||||
payload: import("../src/lib/recordingSession").StoreRecordedSessionInput,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getRecordedVideoPath: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
setRecordingState: (recording: boolean) => Promise<void>;
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
samples: CursorTelemetryPoint[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void;
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
saveExportedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
|
||||
setCurrentRecordingSession: (
|
||||
session: import("../src/lib/recordingSession").RecordingSession | null,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
}>;
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
|
||||
getCurrentRecordingSession: () => Promise<{
|
||||
success: boolean;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
}>;
|
||||
readBinaryFile: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
data?: ArrayBuffer;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
loadProjectFile: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
loadCurrentProjectFile: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
onMenuLoadProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProjectAs: (callback: () => void) => () => void;
|
||||
getPlatform: () => Promise<string>;
|
||||
revealInFolder: (
|
||||
filePath: string,
|
||||
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
getShortcuts: () => Promise<Record<string, unknown> | null>;
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProcessedDesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
display_id: string;
|
||||
thumbnail: string | null;
|
||||
appIcon: string | null;
|
||||
}
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
@@ -1,220 +1,772 @@
|
||||
import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog } from 'electron'
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
dialog,
|
||||
ipcMain,
|
||||
screen,
|
||||
shell,
|
||||
systemPreferences,
|
||||
} from "electron";
|
||||
import {
|
||||
normalizeProjectMedia,
|
||||
normalizeRecordingSession,
|
||||
type RecordingSession,
|
||||
type StoreRecordedSessionInput,
|
||||
} from "../../src/lib/recordingSession";
|
||||
import { RECORDINGS_DIR } from "../main";
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { RECORDINGS_DIR } from '../main'
|
||||
const PROJECT_FILE_EXTENSION = "openscreen";
|
||||
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||
const RECORDING_SESSION_SUFFIX = ".session.json";
|
||||
|
||||
let selectedSource: any = null
|
||||
type SelectedSource = {
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
let selectedSource: SelectedSource | null = null;
|
||||
let currentProjectPath: string | null = null;
|
||||
let currentRecordingSession: RecordingSession | null = null;
|
||||
|
||||
function normalizePath(filePath: string) {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
function normalizeVideoSourcePath(videoPath?: string | null): string | null {
|
||||
if (typeof videoPath !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = videoPath.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^file:\/\//i.test(trimmed)) {
|
||||
try {
|
||||
return fileURLToPath(trimmed);
|
||||
} catch {
|
||||
// Fall through and keep best-effort string path below.
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isTrustedProjectPath(filePath?: string | null) {
|
||||
if (!filePath || !currentProjectPath) {
|
||||
return false;
|
||||
}
|
||||
return normalizePath(filePath) === normalizePath(currentProjectPath);
|
||||
}
|
||||
|
||||
function setCurrentRecordingSessionState(session: RecordingSession | null) {
|
||||
currentRecordingSession = session;
|
||||
}
|
||||
|
||||
function getSessionManifestPathForVideo(videoPath: string) {
|
||||
const parsed = path.parse(videoPath);
|
||||
const baseName = parsed.name.endsWith("-webcam")
|
||||
? parsed.name.slice(0, -"-webcam".length)
|
||||
: parsed.name;
|
||||
return path.join(parsed.dir, `${baseName}${RECORDING_SESSION_SUFFIX}`);
|
||||
}
|
||||
|
||||
async function loadRecordedSessionForVideoPath(
|
||||
videoPath: string,
|
||||
): Promise<RecordingSession | null> {
|
||||
const normalizedVideoPath = normalizeVideoSourcePath(videoPath);
|
||||
if (!normalizedVideoPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifestPath = getSessionManifestPathForVideo(normalizedVideoPath);
|
||||
const content = await fs.readFile(manifestPath, "utf-8");
|
||||
const session = normalizeRecordingSession(JSON.parse(content));
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSession: RecordingSession = {
|
||||
...session,
|
||||
screenVideoPath: normalizeVideoSourcePath(session.screenVideoPath) ?? session.screenVideoPath,
|
||||
...(session.webcamVideoPath
|
||||
? {
|
||||
webcamVideoPath:
|
||||
normalizeVideoSourcePath(session.webcamVideoPath) ?? session.webcamVideoPath,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const targetPath = normalizePath(normalizedVideoPath);
|
||||
const screenMatches = normalizePath(normalizedSession.screenVideoPath) === targetPath;
|
||||
const webcamMatches = normalizedSession.webcamVideoPath
|
||||
? normalizePath(normalizedSession.webcamVideoPath) === targetPath
|
||||
: false;
|
||||
|
||||
return screenMatches || webcamMatches ? normalizedSession : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
|
||||
const createdAt =
|
||||
typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt)
|
||||
? payload.createdAt
|
||||
: Date.now();
|
||||
const screenVideoPath = path.join(RECORDINGS_DIR, payload.screen.fileName);
|
||||
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
|
||||
|
||||
let webcamVideoPath: string | undefined;
|
||||
if (payload.webcam) {
|
||||
webcamVideoPath = path.join(RECORDINGS_DIR, payload.webcam.fileName);
|
||||
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
|
||||
}
|
||||
|
||||
const session: RecordingSession = webcamVideoPath
|
||||
? { screenVideoPath, webcamVideoPath, createdAt }
|
||||
: { screenVideoPath, createdAt };
|
||||
setCurrentRecordingSessionState(session);
|
||||
currentProjectPath = null;
|
||||
|
||||
const telemetryPath = `${screenVideoPath}.cursor.json`;
|
||||
if (pendingCursorSamples.length > 0) {
|
||||
await fs.writeFile(
|
||||
telemetryPath,
|
||||
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
pendingCursorSamples = [];
|
||||
|
||||
const sessionManifestPath = path.join(
|
||||
RECORDINGS_DIR,
|
||||
`${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`,
|
||||
);
|
||||
await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: screenVideoPath,
|
||||
session,
|
||||
message: "Recording session stored successfully",
|
||||
};
|
||||
}
|
||||
|
||||
const CURSOR_TELEMETRY_VERSION = 1;
|
||||
const CURSOR_SAMPLE_INTERVAL_MS = 100;
|
||||
const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
let cursorCaptureInterval: NodeJS.Timeout | null = null;
|
||||
let cursorCaptureStartTimeMs = 0;
|
||||
let activeCursorSamples: CursorTelemetryPoint[] = [];
|
||||
let pendingCursorSamples: CursorTelemetryPoint[] = [];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function stopCursorCapture() {
|
||||
if (cursorCaptureInterval) {
|
||||
clearInterval(cursorCaptureInterval);
|
||||
cursorCaptureInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function sampleCursorPoint() {
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const sourceDisplayId = Number(selectedSource?.display_id);
|
||||
const sourceDisplay = Number.isFinite(sourceDisplayId)
|
||||
? (screen.getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null)
|
||||
: null;
|
||||
const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor);
|
||||
const bounds = display.bounds;
|
||||
const width = Math.max(1, bounds.width);
|
||||
const height = Math.max(1, bounds.height);
|
||||
|
||||
const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
|
||||
const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
|
||||
|
||||
activeCursorSamples.push({
|
||||
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
|
||||
cx,
|
||||
cy,
|
||||
});
|
||||
|
||||
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
|
||||
activeCursorSamples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
createEditorWindow: () => void,
|
||||
createSourceSelectorWindow: () => BrowserWindow,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
getSourceSelectorWindow: () => BrowserWindow | null,
|
||||
onRecordingStateChange?: (recording: boolean, sourceName: string) => void
|
||||
createEditorWindow: () => void,
|
||||
createSourceSelectorWindow: () => BrowserWindow,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
getSourceSelectorWindow: () => BrowserWindow | null,
|
||||
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
|
||||
) {
|
||||
ipcMain.handle('get-sources', async (_, opts) => {
|
||||
const sources = await desktopCapturer.getSources(opts)
|
||||
return sources.map(source => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
display_id: source.display_id,
|
||||
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
|
||||
appIcon: source.appIcon ? source.appIcon.toDataURL() : null
|
||||
}))
|
||||
})
|
||||
ipcMain.handle("get-sources", async (_, opts) => {
|
||||
const sources = await desktopCapturer.getSources(opts);
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
display_id: source.display_id,
|
||||
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
|
||||
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
|
||||
}));
|
||||
});
|
||||
|
||||
ipcMain.handle('select-source', (_, source) => {
|
||||
selectedSource = source
|
||||
const sourceSelectorWin = getSourceSelectorWindow()
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.close()
|
||||
}
|
||||
return selectedSource
|
||||
})
|
||||
ipcMain.handle("select-source", (_, source: SelectedSource) => {
|
||||
selectedSource = source;
|
||||
const sourceSelectorWin = getSourceSelectorWindow();
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.close();
|
||||
}
|
||||
return selectedSource;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-selected-source', () => {
|
||||
return selectedSource
|
||||
})
|
||||
ipcMain.handle("get-selected-source", () => {
|
||||
return selectedSource;
|
||||
});
|
||||
|
||||
ipcMain.handle('open-source-selector', () => {
|
||||
const sourceSelectorWin = getSourceSelectorWindow()
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.focus()
|
||||
return
|
||||
}
|
||||
createSourceSelectorWindow()
|
||||
})
|
||||
ipcMain.handle("request-camera-access", async () => {
|
||||
if (process.platform !== "darwin") {
|
||||
return { success: true, granted: true, status: "granted" };
|
||||
}
|
||||
|
||||
ipcMain.handle('switch-to-editor', () => {
|
||||
const mainWin = getMainWindow()
|
||||
if (mainWin) {
|
||||
mainWin.close()
|
||||
}
|
||||
createEditorWindow()
|
||||
})
|
||||
try {
|
||||
const status = systemPreferences.getMediaAccessStatus("camera");
|
||||
if (status === "granted") {
|
||||
return { success: true, granted: true, status };
|
||||
}
|
||||
|
||||
if (status === "not-determined") {
|
||||
const granted = await systemPreferences.askForMediaAccess("camera");
|
||||
return {
|
||||
success: true,
|
||||
granted,
|
||||
status: granted ? "granted" : systemPreferences.getMediaAccessStatus("camera"),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, granted: false, status };
|
||||
} catch (error) {
|
||||
console.error("Failed to request camera access:", error);
|
||||
return {
|
||||
success: false,
|
||||
granted: false,
|
||||
status: "unknown",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
const videoPath = path.join(RECORDINGS_DIR, fileName)
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData))
|
||||
currentVideoPath = videoPath;
|
||||
return {
|
||||
success: true,
|
||||
path: videoPath,
|
||||
message: 'Video stored successfully'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to store video:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to store video',
|
||||
error: String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle("open-source-selector", () => {
|
||||
const sourceSelectorWin = getSourceSelectorWindow();
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.focus();
|
||||
return;
|
||||
}
|
||||
createSourceSelectorWindow();
|
||||
});
|
||||
|
||||
ipcMain.handle("switch-to-editor", () => {
|
||||
const mainWin = getMainWindow();
|
||||
if (mainWin) {
|
||||
mainWin.close();
|
||||
}
|
||||
createEditorWindow();
|
||||
});
|
||||
|
||||
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
|
||||
try {
|
||||
return await storeRecordedSessionFiles(payload);
|
||||
} catch (error) {
|
||||
console.error("Failed to store recording session:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to store recording session",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-recorded-video-path', async () => {
|
||||
try {
|
||||
const files = await fs.readdir(RECORDINGS_DIR)
|
||||
const videoFiles = files.filter(file => file.endsWith('.webm'))
|
||||
|
||||
if (videoFiles.length === 0) {
|
||||
return { success: false, message: 'No recorded video found' }
|
||||
}
|
||||
|
||||
const latestVideo = videoFiles.sort().reverse()[0]
|
||||
const videoPath = path.join(RECORDINGS_DIR, latestVideo)
|
||||
|
||||
return { success: true, path: videoPath }
|
||||
} catch (error) {
|
||||
console.error('Failed to get video path:', error)
|
||||
return { success: false, message: 'Failed to get video path', error: String(error) }
|
||||
}
|
||||
})
|
||||
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
return await storeRecordedSessionFiles({
|
||||
screen: { videoData, fileName },
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to store recorded video:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to store recorded video",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('set-recording-state', (_, recording: boolean) => {
|
||||
const source = selectedSource || { name: 'Screen' }
|
||||
if (onRecordingStateChange) {
|
||||
onRecordingStateChange(recording, source.name)
|
||||
}
|
||||
})
|
||||
ipcMain.handle("get-recorded-video-path", async () => {
|
||||
try {
|
||||
if (currentRecordingSession?.screenVideoPath) {
|
||||
return { success: true, path: currentRecordingSession.screenVideoPath };
|
||||
}
|
||||
|
||||
const files = await fs.readdir(RECORDINGS_DIR);
|
||||
const videoFiles = files.filter(
|
||||
(file) => file.endsWith(".webm") && !file.endsWith("-webcam.webm"),
|
||||
);
|
||||
|
||||
ipcMain.handle('open-external-url', async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to open URL:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
if (videoFiles.length === 0) {
|
||||
return { success: false, message: "No recorded video found" };
|
||||
}
|
||||
|
||||
// Return base path for assets so renderer can resolve file:// paths in production
|
||||
ipcMain.handle('get-asset-base-path', () => {
|
||||
try {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'assets')
|
||||
}
|
||||
return path.join(app.getAppPath(), 'public', 'assets')
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve asset base path:', err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
const latestVideo = videoFiles.sort().reverse()[0];
|
||||
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
|
||||
|
||||
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
// Determine file type from extension
|
||||
const isGif = fileName.toLowerCase().endsWith('.gif');
|
||||
const filters = isGif
|
||||
? [{ name: 'GIF Image', extensions: ['gif'] }]
|
||||
: [{ name: 'MP4 Video', extensions: ['mp4'] }];
|
||||
return { success: true, path: videoPath };
|
||||
} catch (error) {
|
||||
console.error("Failed to get video path:", error);
|
||||
return { success: false, message: "Failed to get video path", error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: isGif ? 'Save Exported GIF' : 'Save Exported Video',
|
||||
defaultPath: path.join(app.getPath('downloads'), fileName),
|
||||
filters,
|
||||
properties: ['createDirectory', 'showOverwriteConfirmation']
|
||||
});
|
||||
ipcMain.handle("read-binary-file", async (_, inputPath: string) => {
|
||||
try {
|
||||
const normalizedPath = normalizeVideoSourcePath(inputPath);
|
||||
if (!normalizedPath) {
|
||||
return { success: false, message: "Invalid file path" };
|
||||
}
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return {
|
||||
success: false,
|
||||
cancelled: true,
|
||||
message: 'Export cancelled'
|
||||
};
|
||||
}
|
||||
const data = await fs.readFile(normalizedPath);
|
||||
return {
|
||||
success: true,
|
||||
data: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
|
||||
path: normalizedPath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to read binary file:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to read binary file",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
await fs.writeFile(result.filePath, Buffer.from(videoData));
|
||||
ipcMain.handle("set-recording-state", (_, recording: boolean) => {
|
||||
if (recording) {
|
||||
stopCursorCapture();
|
||||
activeCursorSamples = [];
|
||||
pendingCursorSamples = [];
|
||||
cursorCaptureStartTimeMs = Date.now();
|
||||
sampleCursorPoint();
|
||||
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
|
||||
} else {
|
||||
stopCursorCapture();
|
||||
pendingCursorSamples = [...activeCursorSamples];
|
||||
activeCursorSamples = [];
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePath,
|
||||
message: 'Video exported successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to save exported video:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to save exported video',
|
||||
error: String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
const source = selectedSource || { name: "Screen" };
|
||||
if (onRecordingStateChange) {
|
||||
onRecordingStateChange(recording, source.name);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('open-video-file-picker', async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select Video File',
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
filters: [
|
||||
{ name: 'Video Files', extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
],
|
||||
properties: ['openFile']
|
||||
});
|
||||
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
|
||||
const targetVideoPath = normalizeVideoSourcePath(
|
||||
videoPath ?? currentRecordingSession?.screenVideoPath,
|
||||
);
|
||||
if (!targetVideoPath) {
|
||||
return { success: true, samples: [] };
|
||||
}
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
const telemetryPath = `${targetVideoPath}.cursor.json`;
|
||||
try {
|
||||
const content = await fs.readFile(telemetryPath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
const rawSamples = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray(parsed?.samples)
|
||||
? parsed.samples
|
||||
: [];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0]
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to open file picker:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to open file picker',
|
||||
error: String(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
const samples: CursorTelemetryPoint[] = rawSamples
|
||||
.filter((sample: unknown) => Boolean(sample && typeof sample === "object"))
|
||||
.map((sample: unknown) => {
|
||||
const point = sample as Partial<CursorTelemetryPoint>;
|
||||
return {
|
||||
timeMs:
|
||||
typeof point.timeMs === "number" && Number.isFinite(point.timeMs)
|
||||
? Math.max(0, point.timeMs)
|
||||
: 0,
|
||||
cx:
|
||||
typeof point.cx === "number" && Number.isFinite(point.cx)
|
||||
? clamp(point.cx, 0, 1)
|
||||
: 0.5,
|
||||
cy:
|
||||
typeof point.cy === "number" && Number.isFinite(point.cy)
|
||||
? clamp(point.cy, 0, 1)
|
||||
: 0.5,
|
||||
};
|
||||
})
|
||||
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs);
|
||||
|
||||
let currentVideoPath: string | null = null;
|
||||
return { success: true, samples };
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === "ENOENT") {
|
||||
return { success: true, samples: [] };
|
||||
}
|
||||
console.error("Failed to load cursor telemetry:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load cursor telemetry",
|
||||
error: String(error),
|
||||
samples: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('set-current-video-path', (_, path: string) => {
|
||||
currentVideoPath = path;
|
||||
return { success: true };
|
||||
});
|
||||
ipcMain.handle("open-external-url", async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-current-video-path', () => {
|
||||
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
|
||||
});
|
||||
// Return base path for assets so renderer can resolve file:// paths in production
|
||||
ipcMain.handle("get-asset-base-path", () => {
|
||||
try {
|
||||
if (app.isPackaged) {
|
||||
const assetPath = path.join(process.resourcesPath, "assets");
|
||||
return pathToFileURL(`${assetPath}${path.sep}`).toString();
|
||||
}
|
||||
const assetPath = path.join(app.getAppPath(), "public", "assets");
|
||||
return pathToFileURL(`${assetPath}${path.sep}`).toString();
|
||||
} catch (err) {
|
||||
console.error("Failed to resolve asset base path:", err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-current-video-path', () => {
|
||||
currentVideoPath = null;
|
||||
return { success: true };
|
||||
});
|
||||
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
// Determine file type from extension
|
||||
const isGif = fileName.toLowerCase().endsWith(".gif");
|
||||
const filters = isGif
|
||||
? [{ name: "GIF Image", extensions: ["gif"] }]
|
||||
: [{ name: "MP4 Video", extensions: ["mp4"] }];
|
||||
|
||||
ipcMain.handle('get-platform', () => {
|
||||
return process.platform;
|
||||
});
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: isGif ? "Save Exported GIF" : "Save Exported Video",
|
||||
defaultPath: path.join(app.getPath("downloads"), fileName),
|
||||
filters,
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return {
|
||||
success: false,
|
||||
canceled: true,
|
||||
message: "Export canceled",
|
||||
};
|
||||
}
|
||||
|
||||
await fs.writeFile(result.filePath, Buffer.from(videoData));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePath,
|
||||
message: "Video exported successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to save exported video:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to save exported video",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("open-video-file-picker", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Select Video File",
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
filters: [
|
||||
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
currentProjectPath = null;
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to open file picker:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to open file picker",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("reveal-in-folder", async (_, filePath: string) => {
|
||||
try {
|
||||
// shell.showItemInFolder doesn't return a value, it throws on error
|
||||
shell.showItemInFolder(filePath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(`Error revealing item in folder: ${filePath}`, error);
|
||||
// Fallback to open the directory if revealing the item fails
|
||||
// This might happen if the file was moved or deleted after export,
|
||||
// or if the path is somehow invalid for showItemInFolder
|
||||
try {
|
||||
const openPathResult = await shell.openPath(path.dirname(filePath));
|
||||
if (openPathResult) {
|
||||
// openPath returned an error message
|
||||
return { success: false, error: openPathResult };
|
||||
}
|
||||
return { success: true, message: "Could not reveal item, but opened directory." };
|
||||
} catch (openError) {
|
||||
console.error(`Error opening directory: ${path.dirname(filePath)}`, openError);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"save-project-file",
|
||||
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
||||
try {
|
||||
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
|
||||
? existingProjectPath
|
||||
: null;
|
||||
|
||||
if (trustedExistingProjectPath) {
|
||||
await fs.writeFile(
|
||||
trustedExistingProjectPath,
|
||||
JSON.stringify(projectData, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
currentProjectPath = trustedExistingProjectPath;
|
||||
return {
|
||||
success: true,
|
||||
path: trustedExistingProjectPath,
|
||||
message: "Project saved successfully",
|
||||
};
|
||||
}
|
||||
|
||||
const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, "_");
|
||||
const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`)
|
||||
? safeName
|
||||
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Save OpenScreen Project",
|
||||
defaultPath: path.join(RECORDINGS_DIR, defaultName),
|
||||
filters: [
|
||||
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
],
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return {
|
||||
success: false,
|
||||
canceled: true,
|
||||
message: "Save project canceled",
|
||||
};
|
||||
}
|
||||
|
||||
await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), "utf-8");
|
||||
currentProjectPath = result.filePath;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePath,
|
||||
message: "Project saved successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to save project file:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to save project file",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("load-project-file", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Open OpenScreen Project",
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
filters: [
|
||||
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true, message: "Open project canceled" };
|
||||
}
|
||||
|
||||
const filePath = result.filePaths[0];
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const project = JSON.parse(content);
|
||||
currentProjectPath = filePath;
|
||||
if (project && typeof project === "object") {
|
||||
const rawProject = project as { media?: unknown; videoPath?: unknown };
|
||||
const media =
|
||||
normalizeProjectMedia(rawProject.media) ??
|
||||
(typeof rawProject.videoPath === "string"
|
||||
? {
|
||||
screenVideoPath:
|
||||
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
|
||||
}
|
||||
: null);
|
||||
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: filePath,
|
||||
project,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load project file:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load project file",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("load-current-project-file", async () => {
|
||||
try {
|
||||
if (!currentProjectPath) {
|
||||
return { success: false, message: "No active project" };
|
||||
}
|
||||
|
||||
const content = await fs.readFile(currentProjectPath, "utf-8");
|
||||
const project = JSON.parse(content);
|
||||
if (project && typeof project === "object") {
|
||||
const rawProject = project as { media?: unknown; videoPath?: unknown };
|
||||
const media =
|
||||
normalizeProjectMedia(rawProject.media) ??
|
||||
(typeof rawProject.videoPath === "string"
|
||||
? {
|
||||
screenVideoPath:
|
||||
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
|
||||
}
|
||||
: null);
|
||||
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
path: currentProjectPath,
|
||||
project,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load current project file:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load current project file",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
ipcMain.handle("set-current-recording-session", (_, session: RecordingSession | null) => {
|
||||
const normalized = normalizeRecordingSession(session);
|
||||
setCurrentRecordingSessionState(normalized);
|
||||
currentProjectPath = null;
|
||||
return { success: true, session: normalized ?? undefined };
|
||||
});
|
||||
|
||||
ipcMain.handle("get-current-recording-session", () => {
|
||||
return currentRecordingSession
|
||||
? { success: true, session: currentRecordingSession }
|
||||
: { success: false };
|
||||
});
|
||||
|
||||
ipcMain.handle("set-current-video-path", async (_, path: string) => {
|
||||
const restoredSession = await loadRecordedSessionForVideoPath(path);
|
||||
if (restoredSession) {
|
||||
setCurrentRecordingSessionState(restoredSession);
|
||||
} else {
|
||||
setCurrentRecordingSessionState({
|
||||
screenVideoPath: normalizeVideoSourcePath(path) ?? path,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
currentProjectPath = null;
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("get-current-video-path", () => {
|
||||
return currentRecordingSession?.screenVideoPath
|
||||
? { success: true, path: currentRecordingSession.screenVideoPath }
|
||||
: { success: false };
|
||||
});
|
||||
|
||||
ipcMain.handle("clear-current-video-path", () => {
|
||||
setCurrentRecordingSessionState(null);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-shortcuts", async () => {
|
||||
try {
|
||||
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => {
|
||||
try {
|
||||
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to save shortcuts:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs/promises'
|
||||
import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows'
|
||||
import { registerIpcHandlers } from './ipc/handlers'
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
session,
|
||||
systemPreferences,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { registerIpcHandlers } from "./ipc/handlers";
|
||||
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings')
|
||||
// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
|
||||
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
|
||||
// which doesn't work when running from a terminal/IDE during development, makes my life easier
|
||||
if (process.platform === "darwin") {
|
||||
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
|
||||
}
|
||||
|
||||
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
||||
|
||||
async function ensureRecordingsDir() {
|
||||
try {
|
||||
await fs.mkdir(RECORDINGS_DIR, { recursive: true })
|
||||
console.log('RECORDINGS_DIR:', RECORDINGS_DIR)
|
||||
console.log('User Data Path:', app.getPath('userData'))
|
||||
} catch (error) {
|
||||
console.error('Failed to create recordings directory:', error)
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
|
||||
console.log("RECORDINGS_DIR:", RECORDINGS_DIR);
|
||||
console.log("User Data Path:", app.getPath("userData"));
|
||||
} catch (error) {
|
||||
console.error("Failed to create recordings directory:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// The built directory structure
|
||||
@@ -30,137 +45,334 @@ async function ensureRecordingsDir() {
|
||||
// │ │ ├── main.js
|
||||
// │ │ └── preload.mjs
|
||||
// │
|
||||
process.env.APP_ROOT = path.join(__dirname, '..')
|
||||
process.env.APP_ROOT = path.join(__dirname, "..");
|
||||
|
||||
// Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
|
||||
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
|
||||
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
|
||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
|
||||
export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||
export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
|
||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
|
||||
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||||
? path.join(process.env.APP_ROOT, "public")
|
||||
: RENDERER_DIST;
|
||||
|
||||
// Window references
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let sourceSelectorWindow: BrowserWindow | null = null
|
||||
let tray: Tray | null = null
|
||||
let selectedSourceName = ''
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let sourceSelectorWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let selectedSourceName = "";
|
||||
|
||||
// Tray Icons
|
||||
const defaultTrayIcon = getTrayIcon('openscreen.png');
|
||||
const recordingTrayIcon = getTrayIcon('rec-button.png');
|
||||
const defaultTrayIcon = getTrayIcon("openscreen.png");
|
||||
const recordingTrayIcon = getTrayIcon("rec-button.png");
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = createHudOverlayWindow()
|
||||
mainWindow = createHudOverlayWindow();
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
createWindow();
|
||||
}
|
||||
|
||||
function isEditorWindow(window: BrowserWindow) {
|
||||
return window.webContents.getURL().includes("windowType=editor");
|
||||
}
|
||||
|
||||
function sendEditorMenuAction(
|
||||
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
|
||||
) {
|
||||
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
|
||||
|
||||
if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
|
||||
createEditorWindowWrapper();
|
||||
targetWindow = mainWindow;
|
||||
if (!targetWindow || targetWindow.isDestroyed()) return;
|
||||
|
||||
targetWindow.webContents.once("did-finish-load", () => {
|
||||
if (!targetWindow || targetWindow.isDestroyed()) return;
|
||||
targetWindow.webContents.send(channel);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
targetWindow.webContents.send(channel);
|
||||
}
|
||||
|
||||
function setupApplicationMenu() {
|
||||
const isMac = process.platform === "darwin";
|
||||
const template: Electron.MenuItemConstructorOptions[] = [];
|
||||
|
||||
if (isMac) {
|
||||
template.push({
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
template.push(
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "Load Project…",
|
||||
accelerator: "CmdOrCtrl+O",
|
||||
click: () => sendEditorMenuAction("menu-load-project"),
|
||||
},
|
||||
{
|
||||
label: "Save Project…",
|
||||
accelerator: "CmdOrCtrl+S",
|
||||
click: () => sendEditorMenuAction("menu-save-project"),
|
||||
},
|
||||
{
|
||||
label: "Save Project As…",
|
||||
accelerator: "CmdOrCtrl+Shift+S",
|
||||
click: () => sendEditorMenuAction("menu-save-project-as"),
|
||||
},
|
||||
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
submenu: isMac
|
||||
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
|
||||
: [{ role: "minimize" }, { role: "close" }],
|
||||
},
|
||||
);
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
tray = new Tray(defaultTrayIcon);
|
||||
tray = new Tray(defaultTrayIcon);
|
||||
tray.on("click", () => {
|
||||
showMainWindow();
|
||||
});
|
||||
tray.on("double-click", () => {
|
||||
showMainWindow();
|
||||
});
|
||||
}
|
||||
|
||||
function getTrayIcon(filename: string) {
|
||||
return nativeImage.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)).resize({
|
||||
width: 24,
|
||||
height: 24,
|
||||
quality: 'best'
|
||||
});
|
||||
return nativeImage
|
||||
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
|
||||
.resize({
|
||||
width: 24,
|
||||
height: 24,
|
||||
quality: "best",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function updateTrayMenu(recording: boolean = false) {
|
||||
if (!tray) return;
|
||||
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
||||
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
|
||||
const menuTemplate = recording
|
||||
? [
|
||||
{
|
||||
label: "Stop Recording",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("stop-recording-from-tray");
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Open",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.isMinimized() && mainWindow.restore();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
];
|
||||
tray.setImage(trayIcon);
|
||||
tray.setToolTip(trayToolTip);
|
||||
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
|
||||
if (!tray) return;
|
||||
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
||||
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
|
||||
const menuTemplate = recording
|
||||
? [
|
||||
{
|
||||
label: "Stop Recording",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("stop-recording-from-tray");
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Open",
|
||||
click: () => {
|
||||
showMainWindow();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
];
|
||||
tray.setImage(trayIcon);
|
||||
tray.setToolTip(trayToolTip);
|
||||
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
|
||||
}
|
||||
|
||||
let editorHasUnsavedChanges = false;
|
||||
let isForceClosing = false;
|
||||
|
||||
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
|
||||
editorHasUnsavedChanges = hasChanges;
|
||||
});
|
||||
|
||||
function forceCloseEditorWindow(windowToClose: BrowserWindow | null) {
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
isForceClosing = true;
|
||||
setImmediate(() => {
|
||||
try {
|
||||
if (!windowToClose.isDestroyed()) {
|
||||
windowToClose.close();
|
||||
}
|
||||
} finally {
|
||||
isForceClosing = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createEditorWindowWrapper() {
|
||||
if (mainWindow) {
|
||||
mainWindow.close()
|
||||
mainWindow = null
|
||||
}
|
||||
mainWindow = createEditorWindow()
|
||||
if (mainWindow) {
|
||||
isForceClosing = true;
|
||||
mainWindow.close();
|
||||
isForceClosing = false;
|
||||
mainWindow = null;
|
||||
}
|
||||
mainWindow = createEditorWindow();
|
||||
editorHasUnsavedChanges = false;
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (isForceClosing || !editorHasUnsavedChanges) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
||||
type: "warning",
|
||||
buttons: ["Save & Close", "Discard & Close", "Cancel"],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
title: "Unsaved Changes",
|
||||
message: "You have unsaved changes.",
|
||||
detail: "Do you want to save your project before closing?",
|
||||
});
|
||||
|
||||
const windowToClose = mainWindow;
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
if (choice === 0) {
|
||||
// Save & Close — tell renderer to save, then close
|
||||
windowToClose.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
|
||||
if (!shouldClose) return;
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
});
|
||||
} else if (choice === 1) {
|
||||
// Discard & Close
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
}
|
||||
// choice === 2: Cancel — do nothing, window stays open
|
||||
});
|
||||
}
|
||||
|
||||
function createSourceSelectorWindowWrapper() {
|
||||
sourceSelectorWindow = createSourceSelectorWindow()
|
||||
sourceSelectorWindow.on('closed', () => {
|
||||
sourceSelectorWindow = null
|
||||
})
|
||||
return sourceSelectorWindow
|
||||
sourceSelectorWindow = createSourceSelectorWindow();
|
||||
sourceSelectorWindow.on("closed", () => {
|
||||
sourceSelectorWindow = null;
|
||||
});
|
||||
return sourceSelectorWindow;
|
||||
}
|
||||
|
||||
// On macOS, applications and their menu bar stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
// Keep app running (macOS behavior)
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// Keep app running (macOS behavior)
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// Register all IPC handlers when app is ready
|
||||
app.whenReady().then(async () => {
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
const { ipcMain } = await import('electron');
|
||||
ipcMain.on('hud-overlay-close', () => {
|
||||
app.quit();
|
||||
});
|
||||
createTray()
|
||||
updateTrayMenu()
|
||||
// Ensure recordings directory exists
|
||||
await ensureRecordingsDir()
|
||||
// Allow microphone/media permission checks
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
|
||||
return allowed.includes(permission);
|
||||
});
|
||||
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
createSourceSelectorWindowWrapper,
|
||||
() => mainWindow,
|
||||
() => sourceSelectorWindow,
|
||||
(recording: boolean, sourceName: string) => {
|
||||
selectedSourceName = sourceName
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu(recording);
|
||||
if (!recording) {
|
||||
if (mainWindow) mainWindow.restore();
|
||||
}
|
||||
}
|
||||
)
|
||||
createWindow()
|
||||
})
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
|
||||
callback(allowed.includes(permission));
|
||||
});
|
||||
|
||||
// Request microphone permission from macOS
|
||||
if (process.platform === "darwin") {
|
||||
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
||||
if (micStatus !== "granted") {
|
||||
await systemPreferences.askForMediaAccess("microphone");
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
ipcMain.on("hud-overlay-close", () => {
|
||||
app.quit();
|
||||
});
|
||||
createTray();
|
||||
updateTrayMenu();
|
||||
setupApplicationMenu();
|
||||
// Ensure recordings directory exists
|
||||
await ensureRecordingsDir();
|
||||
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
createSourceSelectorWindowWrapper,
|
||||
() => mainWindow,
|
||||
() => sourceSelectorWindow,
|
||||
(recording: boolean, sourceName: string) => {
|
||||
selectedSourceName = sourceName;
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu(recording);
|
||||
if (!recording) {
|
||||
showMainWindow();
|
||||
}
|
||||
},
|
||||
);
|
||||
createWindow();
|
||||
});
|
||||
|
||||
@@ -1,66 +1,136 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
hudOverlayHide: () => {
|
||||
ipcRenderer.send('hud-overlay-hide');
|
||||
},
|
||||
hudOverlayClose: () => {
|
||||
ipcRenderer.send('hud-overlay-close');
|
||||
},
|
||||
getAssetBasePath: async () => {
|
||||
// ask main process for the correct base path (production vs dev)
|
||||
return await ipcRenderer.invoke('get-asset-base-path')
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke('get-sources', opts)
|
||||
},
|
||||
switchToEditor: () => {
|
||||
return ipcRenderer.invoke('switch-to-editor')
|
||||
},
|
||||
openSourceSelector: () => {
|
||||
return ipcRenderer.invoke('open-source-selector')
|
||||
},
|
||||
selectSource: (source: any) => {
|
||||
return ipcRenderer.invoke('select-source', source)
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
return ipcRenderer.invoke('get-selected-source')
|
||||
},
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
hudOverlayHide: () => {
|
||||
ipcRenderer.send("hud-overlay-hide");
|
||||
},
|
||||
hudOverlayClose: () => {
|
||||
ipcRenderer.send("hud-overlay-close");
|
||||
},
|
||||
getAssetBasePath: async () => {
|
||||
// ask main process for the correct base path (production vs dev)
|
||||
return await ipcRenderer.invoke("get-asset-base-path");
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke("get-sources", opts);
|
||||
},
|
||||
switchToEditor: () => {
|
||||
return ipcRenderer.invoke("switch-to-editor");
|
||||
},
|
||||
openSourceSelector: () => {
|
||||
return ipcRenderer.invoke("open-source-selector");
|
||||
},
|
||||
selectSource: (source: ProcessedDesktopSource) => {
|
||||
return ipcRenderer.invoke("select-source", source);
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
return ipcRenderer.invoke("get-selected-source");
|
||||
},
|
||||
requestCameraAccess: () => {
|
||||
return ipcRenderer.invoke("request-camera-access");
|
||||
},
|
||||
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke('store-recorded-video', videoData, fileName)
|
||||
},
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
||||
return ipcRenderer.invoke("store-recorded-session", payload);
|
||||
},
|
||||
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke('get-recorded-video-path')
|
||||
},
|
||||
setRecordingState: (recording: boolean) => {
|
||||
return ipcRenderer.invoke('set-recording-state', recording)
|
||||
},
|
||||
onStopRecordingFromTray: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('stop-recording-from-tray', listener)
|
||||
return () => ipcRenderer.removeListener('stop-recording-from-tray', listener)
|
||||
},
|
||||
openExternalUrl: (url: string) => {
|
||||
return ipcRenderer.invoke('open-external-url', url)
|
||||
},
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke('save-exported-video', videoData, fileName)
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke('open-video-file-picker')
|
||||
},
|
||||
setCurrentVideoPath: (path: string) => {
|
||||
return ipcRenderer.invoke('set-current-video-path', path)
|
||||
},
|
||||
getCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke('get-current-video-path')
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke('clear-current-video-path')
|
||||
},
|
||||
getPlatform: () => {
|
||||
return ipcRenderer.invoke('get-platform')
|
||||
},
|
||||
})
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
},
|
||||
setRecordingState: (recording: boolean) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording);
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||
},
|
||||
onStopRecordingFromTray: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("stop-recording-from-tray", listener);
|
||||
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
|
||||
},
|
||||
openExternalUrl: (url: string) => {
|
||||
return ipcRenderer.invoke("open-external-url", url);
|
||||
},
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-video-file-picker");
|
||||
},
|
||||
setCurrentVideoPath: (path: string) => {
|
||||
return ipcRenderer.invoke("set-current-video-path", path);
|
||||
},
|
||||
setCurrentRecordingSession: (session: RecordingSession | null) => {
|
||||
return ipcRenderer.invoke("set-current-recording-session", session);
|
||||
},
|
||||
getCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-current-video-path");
|
||||
},
|
||||
getCurrentRecordingSession: () => {
|
||||
return ipcRenderer.invoke("get-current-recording-session");
|
||||
},
|
||||
readBinaryFile: (filePath: string) => {
|
||||
return ipcRenderer.invoke("read-binary-file", filePath);
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("clear-current-video-path");
|
||||
},
|
||||
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
||||
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
|
||||
},
|
||||
loadProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-project-file");
|
||||
},
|
||||
loadCurrentProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-current-project-file");
|
||||
},
|
||||
onMenuLoadProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-load-project", listener);
|
||||
return () => ipcRenderer.removeListener("menu-load-project", listener);
|
||||
},
|
||||
onMenuSaveProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-save-project", listener);
|
||||
return () => ipcRenderer.removeListener("menu-save-project", listener);
|
||||
},
|
||||
onMenuSaveProjectAs: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-save-project-as", listener);
|
||||
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
|
||||
},
|
||||
getPlatform: () => {
|
||||
return ipcRenderer.invoke("get-platform");
|
||||
},
|
||||
revealInFolder: (filePath: string) => {
|
||||
return ipcRenderer.invoke("reveal-in-folder", filePath);
|
||||
},
|
||||
getShortcuts: () => {
|
||||
return ipcRenderer.invoke("get-shortcuts");
|
||||
},
|
||||
saveShortcuts: (shortcuts: unknown) => {
|
||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||
},
|
||||
setMicrophoneExpanded: (expanded: boolean) => {
|
||||
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
||||
},
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => {
|
||||
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
||||
},
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
|
||||
const listener = async () => {
|
||||
try {
|
||||
const shouldClose = await callback();
|
||||
ipcRenderer.send("save-before-close-done", shouldClose);
|
||||
} catch {
|
||||
ipcRenderer.send("save-before-close-done", false);
|
||||
}
|
||||
};
|
||||
ipcRenderer.on("request-save-before-close", listener);
|
||||
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,155 +1,154 @@
|
||||
import { BrowserWindow, screen } from 'electron'
|
||||
import { ipcMain } from 'electron'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const APP_ROOT = path.join(__dirname, '..')
|
||||
const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
|
||||
const RENDERER_DIST = path.join(APP_ROOT, 'dist')
|
||||
const APP_ROOT = path.join(__dirname, "..");
|
||||
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||
const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
||||
const HEADLESS = process.env["HEADLESS"] === "true";
|
||||
|
||||
let hudOverlayWindow: BrowserWindow | null = null;
|
||||
|
||||
ipcMain.on('hud-overlay-hide', () => {
|
||||
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
|
||||
hudOverlayWindow.minimize();
|
||||
}
|
||||
ipcMain.on("hud-overlay-hide", () => {
|
||||
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
|
||||
hudOverlayWindow.minimize();
|
||||
}
|
||||
});
|
||||
|
||||
export function createHudOverlayWindow(): BrowserWindow {
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { workArea } = primaryDisplay;
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { workArea } = primaryDisplay;
|
||||
|
||||
const windowWidth = 500;
|
||||
const windowHeight = 155;
|
||||
|
||||
const windowWidth = 500;
|
||||
const windowHeight = 100;
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 500,
|
||||
maxWidth: 500,
|
||||
minHeight: 155,
|
||||
maxHeight: 155,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: !HEADLESS,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 500,
|
||||
maxWidth: 500,
|
||||
minHeight: 100,
|
||||
maxHeight: 100,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
|
||||
hudOverlayWindow = win;
|
||||
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
win?.webContents.send('main-process-message', (new Date).toLocaleString())
|
||||
})
|
||||
win.on("closed", () => {
|
||||
if (hudOverlayWindow === win) {
|
||||
hudOverlayWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
hudOverlayWindow = win;
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=hud-overlay");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "hud-overlay" },
|
||||
});
|
||||
}
|
||||
|
||||
win.on('closed', () => {
|
||||
if (hudOverlayWindow === win) {
|
||||
hudOverlayWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'hud-overlay' }
|
||||
})
|
||||
}
|
||||
|
||||
return win
|
||||
return win;
|
||||
}
|
||||
|
||||
export function createEditorWindow(): BrowserWindow {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
...(isMac && {
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
}),
|
||||
transparent: false,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: 'OpenScreen',
|
||||
backgroundColor: '#000000',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
})
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
...(isMac && {
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
}),
|
||||
transparent: false,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
show: !HEADLESS,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Maximize the window by default
|
||||
win.maximize();
|
||||
// Maximize the window by default
|
||||
win.maximize();
|
||||
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
win?.webContents.send('main-process-message', (new Date).toLocaleString())
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=editor')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'editor' }
|
||||
})
|
||||
}
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=editor");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "editor" },
|
||||
});
|
||||
}
|
||||
|
||||
return win
|
||||
return win;
|
||||
}
|
||||
|
||||
export function createSourceSelectorWindow(): BrowserWindow {
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 620,
|
||||
height: 420,
|
||||
minHeight: 350,
|
||||
maxHeight: 500,
|
||||
x: Math.round((width - 620) / 2),
|
||||
y: Math.round((height - 420) / 2),
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
})
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=source-selector')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'source-selector' }
|
||||
})
|
||||
}
|
||||
const win = new BrowserWindow({
|
||||
width: 620,
|
||||
height: 420,
|
||||
minHeight: 350,
|
||||
maxHeight: 500,
|
||||
x: Math.round((width - 620) / 2),
|
||||
y: Math.round((height - 420) / 2),
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
|
||||
return win
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "source-selector" },
|
||||
});
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 853 KiB |
@@ -1,82 +1,93 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@uiw/color-convert": "^2.9.2",
|
||||
"@uiw/react-color-block": "^2.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"gif.js": "^0.2.0",
|
||||
"gsap": "^3.13.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
"motion": "^12.23.24",
|
||||
"mp4box": "^2.2.0",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-rnd": "^10.5.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"fast-check": "^4.5.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"build-vite": "tsc && vite build",
|
||||
"test:e2e": "playwright test",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@uiw/color-convert": "^2.9.2",
|
||||
"@uiw/react-color-block": "^2.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"gif.js": "^0.2.0",
|
||||
"gsap": "^3.13.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
"motion": "^12.23.24",
|
||||
"mp4box": "^2.2.0",
|
||||
"pixi-filters": "^6.1.5",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-rnd": "^10.5.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^13.0.0",
|
||||
"web-demuxer": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.7.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"fast-check": "^4.5.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"main": "dist-electron/main.js",
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx,mts,cts,json}": "biome check --no-errors-on-unmatched"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 120_000, // GIF encoding is CPU-bound; give it room
|
||||
retries: 0,
|
||||
reporter: "list",
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 776 KiB |
|
Before Width: | Height: | Size: 534 KiB |
|
Before Width: | Height: | Size: 627 KiB |
|
Before Width: | Height: | Size: 460 KiB |
@@ -1,34 +0,0 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<g id="not-lightning" clip-path="url(#clip0_103_2)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 64 64"
|
||||
to="360 64 64"
|
||||
dur="20s"
|
||||
repeatCount="indefinite"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
</g>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
@@ -1,26 +0,0 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_103_2)">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -1,42 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,52 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get('windowType') || '';
|
||||
setWindowType(type);
|
||||
if (type === 'hud-overlay' || type === 'source-selector') {
|
||||
document.body.style.background = 'transparent';
|
||||
document.documentElement.style.background = 'transparent';
|
||||
document.getElementById('root')?.style.setProperty('background', 'transparent');
|
||||
}
|
||||
}, []);
|
||||
|
||||
switch (windowType) {
|
||||
case 'hud-overlay':
|
||||
return <LaunchWindow />;
|
||||
case 'source-selector':
|
||||
return <SourceSelector />;
|
||||
case 'editor':
|
||||
return <VideoEditor />;
|
||||
default:
|
||||
return (
|
||||
<div className="w-full h-full bg-background text-foreground">
|
||||
<h1>Openscreen</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get("windowType") || "";
|
||||
setWindowType(type);
|
||||
if (type === "hud-overlay" || type === "source-selector") {
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.background = "transparent";
|
||||
document.getElementById("root")?.style.setProperty("background", "transparent");
|
||||
}
|
||||
|
||||
// Load custom fonts on app initialization
|
||||
loadAllCustomFonts().catch((error) => {
|
||||
console.error("Failed to load custom fonts:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const content = (() => {
|
||||
switch (windowType) {
|
||||
case "hud-overlay":
|
||||
return <LaunchWindow />;
|
||||
case "source-selector":
|
||||
return <SourceSelector />;
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
<VideoEditor />
|
||||
<ShortcutsConfigDialog />
|
||||
</ShortcutsProvider>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="w-full h-full bg-background text-foreground">
|
||||
<h1>Openscreen</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return <TooltipProvider>{content}</TooltipProvider>;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,8 @@
|
||||
/* Electron-specific: no Tailwind equivalent for -webkit-app-region */
|
||||
.electronDrag {
|
||||
-webkit-app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.electronNoDrag {
|
||||
-webkit-app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
|
||||
.folderButton {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.folderText {
|
||||
color: #cbd5e1;
|
||||
transition: text-decoration 0.15s;
|
||||
}
|
||||
|
||||
.folderButton:hover .folderText {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hudOverlayButton {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.hudOverlayButton:hover {
|
||||
opacity: 0.7;
|
||||
background: none !important;
|
||||
}
|
||||
@@ -1,185 +1,333 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { Button } from "../ui/button";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { MdMonitor } from "react-icons/md";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { FaFolderMinus } from "react-icons/fa6";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
import { FiMinus, FiX } from "react-icons/fi";
|
||||
import { ContentClamp } from "../ui/content-clamp";
|
||||
import {
|
||||
MdMic,
|
||||
MdMicOff,
|
||||
MdMonitor,
|
||||
MdRestartAlt,
|
||||
MdVideocam,
|
||||
MdVideocamOff,
|
||||
MdVideoFile,
|
||||
MdVolumeOff,
|
||||
MdVolumeUp,
|
||||
} from "react-icons/md";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { formatTimePadded } from "../../utils/timeUtils";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { Tooltip } from "../ui/tooltip";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
|
||||
const ICON_SIZE = 20;
|
||||
|
||||
const ICON_CONFIG = {
|
||||
drag: { icon: RxDragHandleDots2, size: ICON_SIZE },
|
||||
monitor: { icon: MdMonitor, size: ICON_SIZE },
|
||||
volumeOn: { icon: MdVolumeUp, size: ICON_SIZE },
|
||||
volumeOff: { icon: MdVolumeOff, size: ICON_SIZE },
|
||||
micOn: { icon: MdMic, size: ICON_SIZE },
|
||||
micOff: { icon: MdMicOff, size: ICON_SIZE },
|
||||
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
|
||||
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
|
||||
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
|
||||
restart: { icon: MdRestartAlt, size: ICON_SIZE },
|
||||
record: { icon: BsRecordCircle, size: ICON_SIZE },
|
||||
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
|
||||
folder: { icon: FaFolderOpen, size: ICON_SIZE },
|
||||
minimize: { icon: FiMinus, size: ICON_SIZE },
|
||||
close: { icon: FiX, size: ICON_SIZE },
|
||||
} as const;
|
||||
|
||||
type IconName = keyof typeof ICON_CONFIG;
|
||||
|
||||
function getIcon(name: IconName, className?: string) {
|
||||
const { icon: Icon, size } = ICON_CONFIG[name];
|
||||
return <Icon size={size} className={className} />;
|
||||
}
|
||||
|
||||
const hudGroupClasses =
|
||||
"flex items-center gap-0.5 bg-white/5 rounded-full transition-colors duration-150 hover:bg-white/[0.08]";
|
||||
|
||||
const hudIconBtnClasses =
|
||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
|
||||
|
||||
const windowBtnClasses =
|
||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
||||
|
||||
export function LaunchWindow() {
|
||||
const { recording, toggleRecording } = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const {
|
||||
recording,
|
||||
toggleRecording,
|
||||
restartRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (recording) {
|
||||
if (!recordingStart) setRecordingStart(Date.now());
|
||||
timer = setInterval(() => {
|
||||
if (recordingStart) {
|
||||
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setRecordingStart(null);
|
||||
setElapsed(0);
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [recording, recordingStart]);
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const { devices, selectedDeviceId, setSelectedDeviceId } =
|
||||
useMicrophoneDevices(microphoneEnabled);
|
||||
const { level } = useAudioLevelMeter({
|
||||
enabled: showMicControls,
|
||||
deviceId: microphoneDeviceId,
|
||||
});
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const s = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
||||
setMicrophoneDeviceId(selectedDeviceId);
|
||||
}
|
||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
if (window.electronAPI) {
|
||||
const source = await window.electronAPI.getSelectedSource();
|
||||
if (source) {
|
||||
setSelectedSource(source.name);
|
||||
setHasSelectedSource(true);
|
||||
} else {
|
||||
setSelectedSource("Screen");
|
||||
setHasSelectedSource(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (recording) {
|
||||
if (!recordingStart) setRecordingStart(Date.now());
|
||||
timer = setInterval(() => {
|
||||
if (recordingStart) {
|
||||
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setRecordingStart(null);
|
||||
setElapsed(0);
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [recording, recordingStart]);
|
||||
|
||||
checkSelectedSource();
|
||||
|
||||
const interval = setInterval(checkSelectedSource, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
|
||||
const openSourceSelector = () => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.openSourceSelector();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
if (window.electronAPI) {
|
||||
const source = await window.electronAPI.getSelectedSource();
|
||||
if (source) {
|
||||
setSelectedSource(source.name);
|
||||
setHasSelectedSource(true);
|
||||
} else {
|
||||
setSelectedSource("Screen");
|
||||
setHasSelectedSource(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
|
||||
if (result.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
checkSelectedSource();
|
||||
|
||||
// IPC events for hide/close
|
||||
const sendHudOverlayHide = () => {
|
||||
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
|
||||
window.electronAPI.hudOverlayHide();
|
||||
}
|
||||
};
|
||||
const sendHudOverlayClose = () => {
|
||||
if (window.electronAPI && window.electronAPI.hudOverlayClose) {
|
||||
window.electronAPI.hudOverlayClose();
|
||||
}
|
||||
};
|
||||
const interval = setInterval(checkSelectedSource, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center bg-transparent">
|
||||
<div
|
||||
className={`w-full max-w-[500px] mx-auto flex items-center justify-between px-4 py-2 ${styles.electronDrag}`}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(135deg, rgba(30,30,40,0.92) 0%, rgba(20,20,30,0.85) 100%)',
|
||||
backdropFilter: 'blur(32px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(32px) saturate(180%)',
|
||||
boxShadow: '0 4px 24px 0 rgba(0,0,0,0.28), 0 1px 3px 0 rgba(0,0,0,0.14) inset',
|
||||
border: '1px solid rgba(80,80,120,0.22)',
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${styles.electronDrag}`}> <RxDragHandleDots2 size={18} className="text-white/40" /> </div>
|
||||
const openSourceSelector = () => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.openSourceSelector();
|
||||
}
|
||||
};
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left text-xs ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
>
|
||||
<MdMonitor size={14} className="text-white" />
|
||||
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
|
||||
</Button>
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
|
||||
<div className="w-px h-6 bg-white/30" />
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-center text-xs ${styles.electronNoDrag}`}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
<FaRegStopCircle size={14} className="text-red-400" />
|
||||
<span className="text-red-400">{formatTime(elapsed)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BsRecordCircle size={14} className={hasSelectedSource ? "text-white" : "text-white/50"} />
|
||||
<span className={hasSelectedSource ? "text-white" : "text-white/50"}>Record</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
|
||||
<div className="w-px h-6 bg-white/30" />
|
||||
const openProjectFile = async () => {
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
if (result.canceled || !result.success) return;
|
||||
await window.electronAPI.switchToEditor();
|
||||
};
|
||||
|
||||
const sendHudOverlayHide = () => {
|
||||
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
|
||||
window.electronAPI.hudOverlayHide();
|
||||
}
|
||||
};
|
||||
const sendHudOverlayClose = () => {
|
||||
if (window.electronAPI && window.electronAPI.hudOverlayClose) {
|
||||
window.electronAPI.hudOverlayClose();
|
||||
}
|
||||
};
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={openVideoFile}
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag} ${styles.folderButton}`}
|
||||
disabled={recording}
|
||||
>
|
||||
<FaFolderMinus size={14} className="text-white" />
|
||||
<span className={styles.folderText}>Open</span>
|
||||
</Button>
|
||||
const toggleMicrophone = () => {
|
||||
if (!recording) {
|
||||
setMicrophoneEnabled(!microphoneEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
{/* Separator before hide/close buttons */}
|
||||
<div className="w-px h-6 bg-white/30 mx-2" />
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className={`ml-2 ${styles.electronNoDrag} hudOverlayButton`}
|
||||
title="Hide HUD"
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
<FiMinus size={18} style={{ color: '#fff', opacity: 0.7 }} />
|
||||
|
||||
</Button>
|
||||
return (
|
||||
<div className="w-full h-full flex items-end justify-center bg-transparent">
|
||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
||||
{/* Mic controls panel */}
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)] rounded-2xl shadow-mic-panel animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||
>
|
||||
<div className="relative flex-1" style={{ maxWidth: "70%" }}>
|
||||
<select
|
||||
value={microphoneDeviceId || selectedDeviceId}
|
||||
onChange={(e) => {
|
||||
setSelectedDeviceId(e.target.value);
|
||||
setMicrophoneDeviceId(e.target.value);
|
||||
}}
|
||||
className="w-full appearance-none bg-white/10 text-white text-xs rounded-full pl-3 pr-7 py-2 border border-white/20 outline-none truncate"
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/60 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
<AudioLevelMeter level={level} className="w-24 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className={`ml-1 ${styles.electronNoDrag} hudOverlayButton`}
|
||||
title="Close App"
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
<FiX size={18} style={{ color: '#fff', opacity: 0.7 }} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Main pill bar */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 isolate rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]">
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
{getIcon("monitor", "text-white/80")}
|
||||
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
|
||||
{selectedSource}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio controls group */}
|
||||
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
|
||||
>
|
||||
{systemAudioEnabled
|
||||
? getIcon("volumeOn", "text-green-400")
|
||||
: getIcon("volumeOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
|
||||
>
|
||||
{microphoneEnabled
|
||||
? getIcon("micOn", "text-green-400")
|
||||
: getIcon("micOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={() => {
|
||||
void setWebcamEnabled(!webcamEnabled);
|
||||
}}
|
||||
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
|
||||
>
|
||||
{webcamEnabled
|
||||
? getIcon("webcamOn", "text-green-400")
|
||||
: getIcon("webcamOff", "text-white/40")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
|
||||
recording ? "animate-record-pulse bg-red-500/10" : "bg-white/5 hover:bg-white/[0.08]"
|
||||
}`}
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
{getIcon("stop", "text-red-400")}
|
||||
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
||||
{formatTimePadded(elapsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Restart recording */}
|
||||
{recording && (
|
||||
<Tooltip content="Restart recording">
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={restartRecording}
|
||||
>
|
||||
{getIcon("restart", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content="Open video file">
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content="Open project">
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Window controls */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<button className={windowBtnClasses} title="Hide HUD" onClick={sendHudOverlayHide}>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button className={windowBtnClasses} title="Close App" onClick={sendHudOverlayClose}>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,99 @@
|
||||
|
||||
.glassContainer {
|
||||
background: linear-gradient(135deg, rgba(28,28,34,0.92) 0%, rgba(18,18,22,0.88) 100%);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 4px 16px 0 rgba(0,0,0,0.32), 0 1px 3px 0 rgba(0,0,0,0.18) inset;
|
||||
border: 1px solid rgba(60,60,80,0.18);
|
||||
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 4px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset;
|
||||
border: 1px solid rgba(60, 60, 80, 0.18);
|
||||
}
|
||||
|
||||
.sourceCard {
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(120deg, rgba(38,38,48,0.98) 0%, rgba(24,24,32,0.96) 100%);
|
||||
border: 1px solid rgba(60,60,80,0.22);
|
||||
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.18);
|
||||
transition: box-shadow 0.2s, border 0.2s, background 0.2s;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
|
||||
border: 1px solid rgba(60, 60, 80, 0.22);
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sourceCard:hover {
|
||||
border-color: rgba(120, 120, 160, 0.35);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.selected {
|
||||
border: 2px solid #34B27B;
|
||||
background: linear-gradient(120deg, rgba(91,33,182,0.18) 0%, rgba(38,38,48,0.98) 100%);
|
||||
box-shadow: 0 0 0 2px #34B27B33;
|
||||
border: 2px solid #34b27b;
|
||||
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
|
||||
box-shadow:
|
||||
0 0 12px rgba(52, 178, 123, 0.15),
|
||||
0 0 4px rgba(52, 178, 123, 0.1);
|
||||
}
|
||||
|
||||
.selected:hover {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: #c7d2fe;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.85rem;
|
||||
color: #f3f4f6;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
font-size: 0.8rem;
|
||||
color: #e4e4e7;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
color: #a1a1aa;
|
||||
font-size: 0.75rem;
|
||||
color: #a1a1aa;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Checkmark badge */
|
||||
.checkBadge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #34b27b;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 8px rgba(52, 178, 123, 0.4);
|
||||
}
|
||||
|
||||
/* scrollbar */
|
||||
.sourceGridScroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-track {
|
||||
background: rgba(30, 30, 38, 0.5);
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(80, 80, 100, 0.6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(52, 178, 123, 0.6);
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(52, 178, 123, 0.8);
|
||||
}
|
||||
|
||||
@@ -1,159 +1,161 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MdCheck } from "react-icons/md";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { Card } from "../ui/card";
|
||||
import styles from "./SourceSelector.module.css";
|
||||
|
||||
interface DesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string | null;
|
||||
display_id: string;
|
||||
appIcon: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string | null;
|
||||
display_id: string;
|
||||
appIcon: string | null;
|
||||
}
|
||||
|
||||
export function SourceSelector() {
|
||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSources() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rawSources = await window.electronAPI.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true
|
||||
});
|
||||
setSources(
|
||||
rawSources.map(source => ({
|
||||
id: source.id,
|
||||
name:
|
||||
source.id.startsWith('window:') && source.name.includes(' — ')
|
||||
? source.name.split(' — ')[1] || source.name
|
||||
: source.name,
|
||||
thumbnail: source.thumbnail,
|
||||
display_id: source.display_id,
|
||||
appIcon: source.appIcon
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error loading sources:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchSources();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
async function fetchSources() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rawSources = await window.electronAPI.getSources({
|
||||
types: ["screen", "window"],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
setSources(
|
||||
rawSources.map((source) => ({
|
||||
id: source.id,
|
||||
name:
|
||||
source.id.startsWith("window:") && source.name.includes(" — ")
|
||||
? source.name.split(" — ")[1] || source.name
|
||||
: source.name,
|
||||
thumbnail: source.thumbnail,
|
||||
display_id: source.display_id,
|
||||
appIcon: source.appIcon,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading sources:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchSources();
|
||||
}, []);
|
||||
|
||||
const screenSources = sources.filter(s => s.id.startsWith('screen:'));
|
||||
const windowSources = sources.filter(s => s.id.startsWith('window:'));
|
||||
const screenSources = sources.filter((s) => s.id.startsWith("screen:"));
|
||||
const windowSources = sources.filter((s) => s.id.startsWith("window:"));
|
||||
|
||||
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
|
||||
const handleShare = async () => {
|
||||
if (selectedSource) await window.electronAPI.selectSource(selectedSource);
|
||||
};
|
||||
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
|
||||
const handleShare = async () => {
|
||||
if (selectedSource) await window.electronAPI.selectSource(selectedSource);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`h-full flex items-center justify-center ${styles.glassContainer}`} style={{ minHeight: '100vh' }}>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-zinc-600 mx-auto mb-2" />
|
||||
<p className="text-xs text-zinc-300">Loading sources...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`h-full flex items-center justify-center ${styles.glassContainer}`}
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
|
||||
<p className="text-xs text-zinc-400">Loading sources...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col items-center justify-center ${styles.glassContainer}`}>
|
||||
<div className="flex-1 flex flex-col w-full max-w-xl" style={{ padding: 0 }}>
|
||||
<Tabs defaultValue="screens">
|
||||
<TabsList className="grid grid-cols-2 mb-3 bg-zinc-900/40 rounded-full">
|
||||
<TabsTrigger value="screens" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Screens</TabsTrigger>
|
||||
<TabsTrigger value="windows" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Windows</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="h-72 flex flex-col justify-stretch">
|
||||
<TabsContent value="screens" className="h-full">
|
||||
<div className="grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative">
|
||||
{screenSources.map(source => (
|
||||
<Card
|
||||
key={source.id}
|
||||
className={`${styles.sourceCard} ${selectedSource?.id === source.id ? styles.selected : ''} cursor-pointer h-fit p-2 scale-95`}
|
||||
style={{ margin: 8, width: '90%', maxWidth: 220 }}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<div className="p-1">
|
||||
<div className="relative mb-1">
|
||||
<img
|
||||
src={source.thumbnail || ''}
|
||||
alt={source.name}
|
||||
className="w-full aspect-video object-cover rounded border border-zinc-800"
|
||||
/>
|
||||
{selectedSource?.id === source.id && (
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<div className="w-4 h-4 bg-[#34B27B] rounded-full flex items-center justify-center shadow-md">
|
||||
<MdCheck className={styles.icon} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.name + " truncate"}>{source.name}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="windows" className="h-full">
|
||||
<div className="grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative">
|
||||
{windowSources.map(source => (
|
||||
<Card
|
||||
key={source.id}
|
||||
className={`${styles.sourceCard} ${selectedSource?.id === source.id ? styles.selected : ''} cursor-pointer h-fit p-2 scale-95`}
|
||||
style={{ margin: 8, width: '90%', maxWidth: 220 }}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<div className="p-1">
|
||||
<div className="relative mb-1">
|
||||
<img
|
||||
src={source.thumbnail || ''}
|
||||
alt={source.name}
|
||||
className="w-full aspect-video object-cover rounded border border-gray-700"
|
||||
/>
|
||||
{selectedSource?.id === source.id && (
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<div className="w-4 h-4 bg-blue-600 rounded-full flex items-center justify-center shadow-md">
|
||||
<MdCheck className={styles.icon} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{source.appIcon && (
|
||||
<img
|
||||
src={source.appIcon}
|
||||
alt="App icon"
|
||||
className={styles.icon + " flex-shrink-0"}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.name + " truncate"}>{source.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="border-t border-zinc-800 p-2 w-full max-w-xl">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => window.close()} className="px-4 py-1 text-xs bg-zinc-800 border-zinc-700 text-zinc-200 hover:bg-zinc-700">Cancel</Button>
|
||||
<Button onClick={handleShare} disabled={!selectedSource} className="px-4 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-50 disabled:bg-zinc-700">Share</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const renderSourceCard = (source: DesktopSource) => {
|
||||
const isSelected = selectedSource?.id === source.id;
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-2`}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<div className="relative mb-1.5">
|
||||
<img
|
||||
src={source.thumbnail || ""}
|
||||
alt={source.name}
|
||||
className="w-full aspect-video object-cover rounded-lg"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1.5 -right-1.5">
|
||||
<div className={styles.checkBadge}>
|
||||
<MdCheck size={12} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{source.appIcon && (
|
||||
<img src={source.appIcon} alt="" className={`${styles.icon} flex-shrink-0`} />
|
||||
)}
|
||||
<div className={`${styles.name} truncate`}>{source.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
|
||||
<div className="flex-1 flex flex-col w-full px-4 pt-4">
|
||||
<Tabs
|
||||
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
|
||||
<TabsTrigger
|
||||
value="screens"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||
>
|
||||
Screens ({screenSources.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="windows"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||
>
|
||||
Windows ({windowSources.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1 min-h-0">
|
||||
<TabsContent value="screens" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{screenSources.map(renderSourceCard)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="windows" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{windowSources.map(renderSourceCard)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="p-3 flex justify-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => window.close()}
|
||||
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
disabled={!selectedSource}
|
||||
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b border-white/5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-3 text-sm font-medium text-slate-200 transition-all hover:text-white [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,37 @@
|
||||
interface AudioLevelMeterProps {
|
||||
level: number; // 0-100
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const bars = [
|
||||
{ threshold: 10, height: "30%" },
|
||||
{ threshold: 25, height: "45%" },
|
||||
{ threshold: 45, height: "60%" },
|
||||
{ threshold: 65, height: "75%" },
|
||||
{ threshold: 85, height: "90%" },
|
||||
];
|
||||
|
||||
function getBarColor(level: number, threshold: number) {
|
||||
if (!level || level < threshold) return "bg-slate-700";
|
||||
if (threshold > 80) return "bg-red-500";
|
||||
if (threshold > 60) return "bg-yellow-500";
|
||||
if (threshold > 40) return "bg-green-500";
|
||||
return "bg-emerald-500";
|
||||
}
|
||||
|
||||
export function AudioLevelMeter({ level, className = "" }: AudioLevelMeterProps) {
|
||||
return (
|
||||
<div className={`flex items-end justify-between gap-1.5 h-6 ${className}`}>
|
||||
{bars.map((bar, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex-1 rounded-sm transition-all duration-100 ease-out ${getBarColor(level, bar.threshold)}`}
|
||||
style={{
|
||||
height: level >= bar.threshold ? bar.height : "15%",
|
||||
opacity: level >= bar.threshold ? 1 : 0.4,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +1,50 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,76 +1,55 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
@@ -1,86 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover"
|
||||
|
||||
interface ContentClampProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode
|
||||
truncateLength?: number
|
||||
}
|
||||
|
||||
function ContentClamp({
|
||||
children,
|
||||
className,
|
||||
truncateLength = 50,
|
||||
...props
|
||||
}: ContentClampProps) {
|
||||
const text = typeof children === "string" ? children : String(children ?? "")
|
||||
const isTruncated = text.length > truncateLength
|
||||
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setOpen(false)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!isTruncated) {
|
||||
return (
|
||||
<div className={cn("inline", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const truncatedText = text.slice(0, truncateLength) + "..."
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={className}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
{...props}
|
||||
>
|
||||
{truncatedText}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto max-w-sm rounded-lg border border-white bg-popover p-3 text-sm text-popover-foreground"
|
||||
sideOffset={8}
|
||||
animated={false}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PopoverArrow className="fill-white" />
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { ContentClamp }
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover";
|
||||
|
||||
interface ContentClampProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
truncateLength?: number;
|
||||
}
|
||||
|
||||
function ContentClamp({ children, className, truncateLength = 50, ...props }: ContentClampProps) {
|
||||
const text = typeof children === "string" ? children : String(children ?? "");
|
||||
const isTruncated = text.length > truncateLength;
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isTruncated) {
|
||||
return (
|
||||
<div className={cn("inline", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const truncatedText = text.slice(0, truncateLength) + "...";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={className}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
{...props}
|
||||
>
|
||||
{truncatedText}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto max-w-sm rounded-lg border border-white bg-popover p-3 text-sm text-popover-foreground"
|
||||
sideOffset={8}
|
||||
animated={false}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PopoverArrow className="fill-white" />
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export { ContentClamp };
|
||||
|
||||
@@ -1,120 +1,102 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[9999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[10000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
|
||||
@@ -1,199 +1,186 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -15,4 +15,4 @@ function ItemContent({ children, classes }: ItemContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemContent;
|
||||
export default ItemContent;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Label.displayName = "Label";
|
||||
|
||||
export { Label };
|
||||
@@ -1,66 +1,60 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
animated = true,
|
||||
...props
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
animated = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
|
||||
animated?: boolean
|
||||
animated?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
|
||||
animated &&
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 origin-(--radix-popover-content-transform-origin)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
|
||||
animated &&
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 origin-(--radix-popover-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverArrow({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
|
||||
return (
|
||||
<PopoverPrimitive.Arrow
|
||||
data-slot="popover-arrow"
|
||||
className={cn("fill-popover", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<PopoverPrimitive.Arrow
|
||||
data-slot="popover-arrow"
|
||||
className={cn("fill-popover", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow }
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow };
|
||||
|
||||
@@ -1,160 +1,153 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-[#34B27B]" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-[#34B27B] bg-[#34B27B] shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-[#34B27B]" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-[#34B27B] bg-[#34B27B] shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider }
|
||||
export { Slider };
|
||||
|
||||
@@ -3,25 +3,23 @@ import { Toaster as Sonner } from "sonner";
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
duration={3000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
duration={3000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#23232a]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform",
|
||||
"bg-[#f5f5f7] dark:bg-[#23232a]",
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#23232a]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform",
|
||||
"bg-[#f5f5f7] dark:bg-[#23232a]",
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch }
|
||||
export { Switch };
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1,61 +1,56 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
));
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-9 px-2.5",
|
||||
lg: "h-11 px-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-9 px-2.5",
|
||||
lg: "h-11 px-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
export { Toggle, toggleVariants };
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 200,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipRoot({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[11px] leading-none text-white/90 bg-black/85 border border-white/10 rounded-md z-50",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
children,
|
||||
content,
|
||||
side,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, Tooltip };
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
addCustomFont,
|
||||
type CustomFont,
|
||||
generateFontId,
|
||||
isValidGoogleFontsUrl,
|
||||
parseFontFamilyFromImport,
|
||||
} from "@/lib/customFonts";
|
||||
|
||||
interface AddCustomFontDialogProps {
|
||||
onFontAdded?: (font: CustomFont) => void;
|
||||
}
|
||||
|
||||
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
const [fontName, setFontName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleImportUrlChange = (url: string) => {
|
||||
setImportUrl(url);
|
||||
|
||||
// Auto-extract font name if valid Google Fonts URL
|
||||
if (isValidGoogleFontsUrl(url)) {
|
||||
const extracted = parseFontFamilyFromImport(url);
|
||||
if (extracted && !fontName) {
|
||||
setFontName(extracted);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
// Validate inputs
|
||||
if (!importUrl.trim()) {
|
||||
toast.error("Please enter a Google Fonts import URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidGoogleFontsUrl(importUrl)) {
|
||||
toast.error("Please enter a valid Google Fonts URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fontName.trim()) {
|
||||
toast.error("Please enter a font name");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Extract font family from URL
|
||||
const fontFamily = parseFontFamilyFromImport(importUrl);
|
||||
if (!fontFamily) {
|
||||
toast.error("Could not extract font family from URL");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create custom font object
|
||||
const newFont: CustomFont = {
|
||||
id: generateFontId(fontName),
|
||||
name: fontName.trim(),
|
||||
fontFamily: fontFamily,
|
||||
importUrl: importUrl.trim(),
|
||||
};
|
||||
|
||||
// Add font (this will load and verify it) - throws if it fails
|
||||
await addCustomFont(newFont);
|
||||
|
||||
// Notify parent
|
||||
if (onFontAdded) {
|
||||
onFontAdded(newFont);
|
||||
}
|
||||
|
||||
toast.success(`Font "${fontName}" added successfully`);
|
||||
|
||||
// Reset and close
|
||||
setImportUrl("");
|
||||
setFontName("");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to add custom font:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to load font";
|
||||
toast.error("Failed to add font", {
|
||||
description: errorMessage.includes("timeout")
|
||||
? "Font took too long to load. Please check the URL and try again."
|
||||
: "The font could not be loaded. Please verify the Google Fonts URL is correct.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Google Font
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Google Font</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Add a custom font from Google Fonts to use in your annotations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-url" className="text-slate-200">
|
||||
Google Fonts Import URL
|
||||
</Label>
|
||||
<Input
|
||||
id="import-url"
|
||||
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
|
||||
value={importUrl}
|
||||
onChange={(e) => handleImportUrlChange(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font-name" className="text-slate-200">
|
||||
Display Name
|
||||
</Label>
|
||||
<Input
|
||||
id="font-name"
|
||||
placeholder="My Custom Font"
|
||||
value={fontName}
|
||||
onChange={(e) => setFontName(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
This is how the font will appear in the font selector
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{loading ? "Adding..." : "Add Font"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +1,220 @@
|
||||
import { useRef } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
annotation: AnnotationRegion;
|
||||
isSelected: boolean;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
annotation: AnnotationRegion;
|
||||
isSelected: boolean;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
annotation,
|
||||
isSelected,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
annotation,
|
||||
isSelected,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
}: AnnotationOverlayProps) {
|
||||
const x = (annotation.position.x / 100) * containerWidth;
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
const x = (annotation.position.x / 100) * containerWidth;
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || 'right';
|
||||
const color = annotation.figureData?.color || '#34B27B';
|
||||
const strokeWidth = annotation.figureData?.strokeWidth || 4;
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || "right";
|
||||
const color = annotation.figureData?.color || "#34B27B";
|
||||
const strokeWidth = annotation.figureData?.strokeWidth || 4;
|
||||
|
||||
const ArrowComponent = getArrowComponent(direction);
|
||||
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
||||
};
|
||||
const ArrowComponent = getArrowComponent(direction);
|
||||
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (annotation.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex items-center p-2 overflow-hidden"
|
||||
style={{
|
||||
justifyContent: annotation.style.textAlign === 'left' ? 'flex-start' :
|
||||
annotation.style.textAlign === 'right' ? 'flex-end' : 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: annotation.style.color,
|
||||
backgroundColor: annotation.style.backgroundColor,
|
||||
fontSize: `${annotation.style.fontSize}px`,
|
||||
fontFamily: annotation.style.fontFamily,
|
||||
fontWeight: annotation.style.fontWeight,
|
||||
fontStyle: annotation.style.fontStyle,
|
||||
textDecoration: annotation.style.textDecoration,
|
||||
textAlign: annotation.style.textAlign,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
boxDecorationBreak: 'clone',
|
||||
WebkitBoxDecorationBreak: 'clone',
|
||||
padding: '0.1em 0.2em',
|
||||
borderRadius: '4px',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{annotation.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
const renderContent = () => {
|
||||
switch (annotation.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex items-center p-2 overflow-hidden"
|
||||
style={{
|
||||
justifyContent:
|
||||
annotation.style.textAlign === "left"
|
||||
? "flex-start"
|
||||
: annotation.style.textAlign === "right"
|
||||
? "flex-end"
|
||||
: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: annotation.style.color,
|
||||
backgroundColor: annotation.style.backgroundColor,
|
||||
fontSize: `${annotation.style.fontSize}px`,
|
||||
fontFamily: annotation.style.fontFamily,
|
||||
fontWeight: annotation.style.fontWeight,
|
||||
fontStyle: annotation.style.fontStyle,
|
||||
textDecoration: annotation.style.textDecoration,
|
||||
textAlign: annotation.style.textAlign,
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
boxDecorationBreak: "clone",
|
||||
WebkitBoxDecorationBreak: "clone",
|
||||
padding: "0.1em 0.2em",
|
||||
borderRadius: "4px",
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
>
|
||||
{annotation.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'image':
|
||||
if (annotation.content && annotation.content.startsWith('data:image')) {
|
||||
return (
|
||||
<img
|
||||
src={annotation.content}
|
||||
alt="Annotation"
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
|
||||
No image
|
||||
</div>
|
||||
);
|
||||
case "image":
|
||||
if (annotation.content && annotation.content.startsWith("data:image")) {
|
||||
return (
|
||||
<img
|
||||
src={annotation.content}
|
||||
alt="Annotation"
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
|
||||
No image
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'figure':
|
||||
if (!annotation.figureData) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
|
||||
No arrow data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "figure":
|
||||
if (!annotation.figureData) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
|
||||
No arrow data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">
|
||||
{renderArrow()}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
position={{ x, y }}
|
||||
size={{ width, height }}
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
const xPercent = (d.x / containerWidth) * 100;
|
||||
const yPercent = (d.y / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
|
||||
// Reset dragging flag after a short delay to prevent click event
|
||||
setTimeout(() => {
|
||||
isDraggingRef.current = false;
|
||||
}, 100);
|
||||
}}
|
||||
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
||||
const xPercent = (position.x / containerWidth) * 100;
|
||||
const yPercent = (position.y / containerHeight) * 100;
|
||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||
const heightPercent = (ref.offsetHeight / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
onSizeChange(annotation.id, { width: widthPercent, height: heightPercent });
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isDraggingRef.current) return;
|
||||
onClick(annotation.id);
|
||||
}}
|
||||
bounds="parent"
|
||||
className={cn(
|
||||
"cursor-move transition-all",
|
||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent"
|
||||
)}
|
||||
style={{
|
||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||
pointerEvents: isSelected ? 'auto' : 'none',
|
||||
border: isSelected ? '2px solid rgba(52, 178, 123, 0.8)' : 'none',
|
||||
backgroundColor: isSelected ? 'rgba(52, 178, 123, 0.1)' : 'transparent',
|
||||
boxShadow: isSelected ? '0 0 0 1px rgba(52, 178, 123, 0.35)' : 'none',
|
||||
}}
|
||||
enableResizing={isSelected}
|
||||
disableDragging={!isSelected}
|
||||
resizeHandleStyles={{
|
||||
topLeft: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: isSelected ? 'white' : 'transparent',
|
||||
border: isSelected ? '2px solid #34B27B' : 'none',
|
||||
borderRadius: '50%',
|
||||
left: '-6px',
|
||||
top: '-6px',
|
||||
cursor: 'nwse-resize',
|
||||
},
|
||||
topRight: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: isSelected ? 'white' : 'transparent',
|
||||
border: isSelected ? '2px solid #34B27B' : 'none',
|
||||
borderRadius: '50%',
|
||||
right: '-6px',
|
||||
top: '-6px',
|
||||
cursor: 'nesw-resize',
|
||||
},
|
||||
bottomLeft: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: isSelected ? 'white' : 'transparent',
|
||||
border: isSelected ? '2px solid #34B27B' : 'none',
|
||||
borderRadius: '50%',
|
||||
left: '-6px',
|
||||
bottom: '-6px',
|
||||
cursor: 'nesw-resize',
|
||||
},
|
||||
bottomRight: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: isSelected ? 'white' : 'transparent',
|
||||
border: isSelected ? '2px solid #34B27B' : 'none',
|
||||
borderRadius: '50%',
|
||||
right: '-6px',
|
||||
bottom: '-6px',
|
||||
cursor: 'nwse-resize',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full rounded-lg",
|
||||
annotation.type === 'text' && "bg-transparent",
|
||||
annotation.type === 'image' && "bg-transparent",
|
||||
annotation.type === 'figure' && "bg-transparent",
|
||||
isSelected && "shadow-lg"
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</Rnd>
|
||||
);
|
||||
return (
|
||||
<Rnd
|
||||
position={{ x, y }}
|
||||
size={{ width, height }}
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
const xPercent = (d.x / containerWidth) * 100;
|
||||
const yPercent = (d.y / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
|
||||
// Reset dragging flag after a short delay to prevent click event
|
||||
setTimeout(() => {
|
||||
isDraggingRef.current = false;
|
||||
}, 100);
|
||||
}}
|
||||
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
||||
const xPercent = (position.x / containerWidth) * 100;
|
||||
const yPercent = (position.y / containerHeight) * 100;
|
||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||
const heightPercent = (ref.offsetHeight / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
onSizeChange(annotation.id, { width: widthPercent, height: heightPercent });
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isDraggingRef.current) return;
|
||||
onClick(annotation.id);
|
||||
}}
|
||||
bounds="parent"
|
||||
className={cn(
|
||||
"cursor-move transition-all",
|
||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||
pointerEvents: isSelected ? "auto" : "none",
|
||||
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
}}
|
||||
enableResizing={isSelected}
|
||||
disableDragging={!isSelected}
|
||||
resizeHandleStyles={{
|
||||
topLeft: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: isSelected ? "white" : "transparent",
|
||||
border: isSelected ? "2px solid #34B27B" : "none",
|
||||
borderRadius: "50%",
|
||||
left: "-6px",
|
||||
top: "-6px",
|
||||
cursor: "nwse-resize",
|
||||
},
|
||||
topRight: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: isSelected ? "white" : "transparent",
|
||||
border: isSelected ? "2px solid #34B27B" : "none",
|
||||
borderRadius: "50%",
|
||||
right: "-6px",
|
||||
top: "-6px",
|
||||
cursor: "nesw-resize",
|
||||
},
|
||||
bottomLeft: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: isSelected ? "white" : "transparent",
|
||||
border: isSelected ? "2px solid #34B27B" : "none",
|
||||
borderRadius: "50%",
|
||||
left: "-6px",
|
||||
bottom: "-6px",
|
||||
cursor: "nesw-resize",
|
||||
},
|
||||
bottomRight: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: isSelected ? "white" : "transparent",
|
||||
border: isSelected ? "2px solid #34B27B" : "none",
|
||||
borderRadius: "50%",
|
||||
right: "-6px",
|
||||
bottom: "-6px",
|
||||
cursor: "nwse-resize",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full rounded-lg",
|
||||
annotation.type === "text" && "bg-transparent",
|
||||
annotation.type === "image" && "bg-transparent",
|
||||
annotation.type === "figure" && "bg-transparent",
|
||||
isSelected && "shadow-lg",
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</Rnd>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ArrowDirection } from './types';
|
||||
import type { ArrowDirection } from "./types";
|
||||
|
||||
interface ArrowSvgProps {
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
className?: string;
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -13,182 +13,190 @@ interface ArrowSvgProps {
|
||||
*/
|
||||
|
||||
export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 20 L 35 35 M 50 20 L 65 35"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 20 L 35 35 M 50 20 L 65 35"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDown({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 80 L 35 65 M 50 80 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 80 L 35 65 M 50 80 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 80 50 L 20 50 M 20 50 L 35 35 M 20 50 L 35 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 80 50 L 20 50 M 20 50 L 35 35 M 20 50 L 35 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 20 50 L 80 50 M 80 50 L 65 35 M 80 50 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 20 50 L 80 50 M 80 50 L 65 35 M 80 50 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 75 L 75 25 M 75 25 L 60 30 M 75 25 L 70 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 75 L 75 25 M 75 25 L 60 30 M 75 25 L 70 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 75 L 25 25 M 25 25 L 40 30 M 25 25 L 30 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 75 L 25 25 M 25 25 L 40 30 M 25 25 L 30 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 25 L 75 75 M 75 75 L 70 60 M 75 75 L 60 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 25 L 75 75 M 75 75 L 70 60 M 75 75 L 60 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 25 L 25 75 M 25 75 L 30 60 M 25 75 L 40 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 25 L 25 75 M 25 75 L 30 60 M 25 75 L 40 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function getArrowComponent(direction: ArrowDirection) {
|
||||
switch (direction) {
|
||||
case 'up': return ArrowUp;
|
||||
case 'down': return ArrowDown;
|
||||
case 'left': return ArrowLeft;
|
||||
case 'right': return ArrowRight;
|
||||
case 'up-right': return ArrowUpRight;
|
||||
case 'up-left': return ArrowUpLeft;
|
||||
case 'down-right': return ArrowDownRight;
|
||||
case 'down-left': return ArrowDownLeft;
|
||||
}
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return ArrowUp;
|
||||
case "down":
|
||||
return ArrowDown;
|
||||
case "left":
|
||||
return ArrowLeft;
|
||||
case "right":
|
||||
return ArrowRight;
|
||||
case "up-right":
|
||||
return ArrowUpRight;
|
||||
case "up-left":
|
||||
return ArrowUpLeft;
|
||||
case "down-right":
|
||||
return ArrowDownRight;
|
||||
case "down-left":
|
||||
return ArrowDownLeft;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,231 +3,250 @@ import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
interface CropRegion {
|
||||
x: number; // 0-1 normalized
|
||||
y: number; // 0-1 normalized
|
||||
width: number; // 0-1 normalized
|
||||
height: number; // 0-1 normalized
|
||||
x: number; // 0-1 normalized
|
||||
y: number; // 0-1 normalized
|
||||
width: number; // 0-1 normalized
|
||||
height: number; // 0-1 normalized
|
||||
}
|
||||
|
||||
interface CropControlProps {
|
||||
videoElement: HTMLVideoElement | null;
|
||||
cropRegion: CropRegion;
|
||||
onCropChange: (region: CropRegion) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
videoElement: HTMLVideoElement | null;
|
||||
cropRegion: CropRegion;
|
||||
onCropChange: (region: CropRegion) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
}
|
||||
|
||||
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
|
||||
type DragHandle = "top" | "right" | "bottom" | "left" | "move" | null;
|
||||
|
||||
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState<DragHandle>(null);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState<DragHandle>(null);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoElement || !canvasRef.current) return;
|
||||
useEffect(() => {
|
||||
if (!videoElement || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
if (!ctx) return;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = videoElement.videoWidth || 1920;
|
||||
canvas.height = videoElement.videoHeight || 1080;
|
||||
canvas.width = videoElement.videoWidth || 1920;
|
||||
canvas.height = videoElement.videoHeight || 1080;
|
||||
|
||||
const draw = () => {
|
||||
if (videoElement.readyState >= 2) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
};
|
||||
const draw = () => {
|
||||
if (videoElement.readyState >= 2) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const rafId = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [videoElement]);
|
||||
const rafId = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [videoElement]);
|
||||
|
||||
const getContainerRect = () => {
|
||||
return containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 };
|
||||
};
|
||||
const getContainerRect = () => {
|
||||
return (
|
||||
containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 }
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsDragging(handle);
|
||||
const rect = getContainerRect();
|
||||
setDragStart({
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
});
|
||||
setInitialCrop(cropRegion);
|
||||
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsDragging(handle);
|
||||
const rect = getContainerRect();
|
||||
setDragStart({
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
});
|
||||
setInitialCrop(cropRegion);
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const rect = getContainerRect();
|
||||
const currentX = (e.clientX - rect.left) / rect.width;
|
||||
const currentY = (e.clientY - rect.top) / rect.height;
|
||||
const deltaX = currentX - dragStart.x;
|
||||
const deltaY = currentY - dragStart.y;
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
let newCrop = { ...initialCrop };
|
||||
const rect = getContainerRect();
|
||||
const currentX = (e.clientX - rect.left) / rect.width;
|
||||
const currentY = (e.clientY - rect.top) / rect.height;
|
||||
const deltaX = currentX - dragStart.x;
|
||||
const deltaY = currentY - dragStart.y;
|
||||
|
||||
switch (isDragging) {
|
||||
case 'top': {
|
||||
const newY = Math.max(0, initialCrop.y + deltaY);
|
||||
const bottom = initialCrop.y + initialCrop.height;
|
||||
newCrop.y = Math.min(newY, bottom - 0.1);
|
||||
newCrop.height = bottom - newCrop.y;
|
||||
break;
|
||||
}
|
||||
case 'bottom':
|
||||
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
|
||||
break;
|
||||
case 'left': {
|
||||
const newX = Math.max(0, initialCrop.x + deltaX);
|
||||
const right = initialCrop.x + initialCrop.width;
|
||||
newCrop.x = Math.min(newX, right - 0.1);
|
||||
newCrop.width = right - newCrop.x;
|
||||
break;
|
||||
}
|
||||
case 'right':
|
||||
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
|
||||
break;
|
||||
}
|
||||
let newCrop = { ...initialCrop };
|
||||
|
||||
onCropChange(newCrop);
|
||||
};
|
||||
switch (isDragging) {
|
||||
case "top": {
|
||||
const newY = Math.max(0, initialCrop.y + deltaY);
|
||||
const bottom = initialCrop.y + initialCrop.height;
|
||||
newCrop.y = Math.min(newY, bottom - 0.1);
|
||||
newCrop.height = bottom - newCrop.y;
|
||||
break;
|
||||
}
|
||||
case "bottom":
|
||||
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
|
||||
break;
|
||||
case "left": {
|
||||
const newX = Math.max(0, initialCrop.x + deltaX);
|
||||
const right = initialCrop.x + initialCrop.width;
|
||||
newCrop.x = Math.min(newX, right - 0.1);
|
||||
newCrop.width = right - newCrop.x;
|
||||
break;
|
||||
}
|
||||
case "right":
|
||||
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
|
||||
break;
|
||||
case "move": {
|
||||
newCrop.x = Math.max(0, Math.min(initialCrop.x + deltaX, 1 - initialCrop.width));
|
||||
newCrop.y = Math.max(0, Math.min(initialCrop.y + deltaY, 1 - initialCrop.height));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (isDragging) {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
setIsDragging(null);
|
||||
};
|
||||
onCropChange(newCrop);
|
||||
};
|
||||
|
||||
const cropPixelX = cropRegion.x * 100;
|
||||
const cropPixelY = cropRegion.y * 100;
|
||||
const cropPixelWidth = cropRegion.width * 100;
|
||||
const cropPixelHeight = cropRegion.height * 100;
|
||||
const videoAspectRatio = videoElement ? videoElement.videoWidth / videoElement.videoHeight : 16/9;
|
||||
const isVideoPortrait = videoAspectRatio < 1;
|
||||
const maxContainerWidth = isVideoPortrait ? '40vw' : '75vw';
|
||||
const maxContainerHeight = '75vh';
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (isDragging) {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// Pointer may already be released; ignore.
|
||||
}
|
||||
}
|
||||
setIsDragging(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
|
||||
style={{
|
||||
aspectRatio: videoAspectRatio,
|
||||
maxWidth: maxContainerWidth,
|
||||
maxHeight: maxContainerHeight,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ imageRendering: 'auto' }}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ transition: 'none' }}>
|
||||
<svg width="100%" height="100%" className="absolute inset-0" style={{ transition: 'none' }}>
|
||||
<defs>
|
||||
<mask id="cropMask">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<rect
|
||||
x={`${cropPixelX}%`}
|
||||
y={`${cropPixelY}%`}
|
||||
width={`${cropPixelWidth}%`}
|
||||
height={`${cropPixelHeight}%`}
|
||||
fill="black"
|
||||
style={{ transition: 'none' }}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="black"
|
||||
fillOpacity="0.6"
|
||||
mask="url(#cropMask)"
|
||||
style={{ transition: 'none' }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
const cropPixelX = cropRegion.x * 100;
|
||||
const cropPixelY = cropRegion.y * 100;
|
||||
const cropPixelWidth = cropRegion.width * 100;
|
||||
const cropPixelHeight = cropRegion.height * 100;
|
||||
const videoAspectRatio = videoElement
|
||||
? videoElement.videoWidth / videoElement.videoHeight
|
||||
: 16 / 9;
|
||||
const isVideoPortrait = videoAspectRatio < 1;
|
||||
const maxContainerWidth = isVideoPortrait ? "40vw" : "75vw";
|
||||
const maxContainerHeight = "75vh";
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'top')}
|
||||
/>
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
|
||||
style={{
|
||||
aspectRatio: videoAspectRatio,
|
||||
maxWidth: maxContainerWidth,
|
||||
maxHeight: maxContainerHeight,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ imageRendering: "auto" }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY + cropPixelHeight}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'bottom')}
|
||||
/>
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ transition: "none" }}>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="absolute inset-0"
|
||||
style={{ transition: "none" }}
|
||||
>
|
||||
<defs>
|
||||
<mask id="cropMask">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<rect
|
||||
x={`${cropPixelX}%`}
|
||||
y={`${cropPixelY}%`}
|
||||
width={`${cropPixelWidth}%`}
|
||||
height={`${cropPixelHeight}%`}
|
||||
fill="black"
|
||||
style={{ transition: "none" }}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="black"
|
||||
fillOpacity="0.6"
|
||||
mask="url(#cropMask)"
|
||||
style={{ transition: "none" }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'left')}
|
||||
/>
|
||||
<div
|
||||
className="absolute z-10 pointer-events-auto cursor-move"
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "move")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX + cropPixelWidth}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div
|
||||
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
transform: "translateY(-50%)",
|
||||
willChange: "transform",
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "top")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY + cropPixelHeight}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
transform: "translateY(-50%)",
|
||||
willChange: "transform",
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "bottom")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn("absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transform: "translateX(-50%)",
|
||||
willChange: "transform",
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "left")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn("absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||
style={{
|
||||
left: `${cropPixelX + cropPixelWidth}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transform: "translateX(-50%)",
|
||||
willChange: "transform",
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "right")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,224 +1,263 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, Download, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ExportProgress } from '@/lib/exporter';
|
||||
import { Download, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ExportProgress } from "@/lib/exporter";
|
||||
|
||||
interface ExportDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
progress: ExportProgress | null;
|
||||
isExporting: boolean;
|
||||
error: string | null;
|
||||
onCancel?: () => void;
|
||||
exportFormat?: 'mp4' | 'gif';
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
progress: ExportProgress | null;
|
||||
isExporting: boolean;
|
||||
error: string | null;
|
||||
onCancel?: () => void;
|
||||
exportFormat?: "mp4" | "gif";
|
||||
exportedFilePath?: string;
|
||||
onShowInFolder?: () => void;
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
progress,
|
||||
isExporting,
|
||||
error,
|
||||
onCancel,
|
||||
exportFormat = 'mp4',
|
||||
isOpen,
|
||||
onClose,
|
||||
progress,
|
||||
isExporting,
|
||||
error,
|
||||
onCancel,
|
||||
exportFormat = "mp4",
|
||||
exportedFilePath,
|
||||
onShowInFolder,
|
||||
}: ExportDialogProps) {
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExporting && progress && progress.percentage >= 100 && !error) {
|
||||
setShowSuccess(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isExporting, progress, error, onClose]);
|
||||
// Reset showSuccess when a new export starts or dialog reopens
|
||||
useEffect(() => {
|
||||
if (isExporting) {
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}, [isExporting]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
// Reset showSuccess when dialog opens fresh
|
||||
useEffect(() => {
|
||||
if (isOpen && !isExporting && !progress) {
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}, [isOpen, isExporting, progress]);
|
||||
|
||||
const formatLabel = exportFormat === 'gif' ? 'GIF' : 'Video';
|
||||
|
||||
// Determine if we're in the compiling phase (frames done but still exporting)
|
||||
const isCompiling = isExporting && progress && progress.percentage >= 100 && exportFormat === 'gif';
|
||||
const isFinalizing = progress?.phase === 'finalizing';
|
||||
const renderProgress = progress?.renderProgress;
|
||||
|
||||
// Get status message based on phase
|
||||
const getStatusMessage = () => {
|
||||
if (error) return 'Please try again';
|
||||
if (isCompiling || isFinalizing) {
|
||||
if (renderProgress !== undefined && renderProgress > 0) {
|
||||
return `Compiling GIF... ${renderProgress}%`;
|
||||
}
|
||||
return 'Compiling GIF... This may take a while';
|
||||
}
|
||||
return 'This may take a moment...';
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isExporting && progress && progress.percentage >= 100 && !error) {
|
||||
setShowSuccess(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isExporting, progress, error, onClose]);
|
||||
|
||||
// Get title based on phase
|
||||
const getTitle = () => {
|
||||
if (error) return 'Export Failed';
|
||||
if (isCompiling || isFinalizing) return 'Compiling GIF';
|
||||
return `Exporting ${formatLabel}`;
|
||||
};
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 animate-in fade-in duration-200"
|
||||
onClick={isExporting ? undefined : onClose}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSuccess ? (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-full bg-[#34B27B]/20 flex items-center justify-center ring-1 ring-[#34B27B]/50">
|
||||
<Download className="w-6 h-6 text-[#34B27B]" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
|
||||
<span className="text-sm text-slate-400">Your {formatLabel.toLowerCase()} is ready</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isExporting ? (
|
||||
<div className="w-12 h-12 rounded-full bg-[#34B27B]/10 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 text-[#34B27B] animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
|
||||
<Download className="w-6 h-6 text-slate-200" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">
|
||||
{getTitle()}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{getStatusMessage()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isExporting && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="hover:bg-white/10 text-slate-400 hover:text-white rounded-full"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
const formatLabel = exportFormat === "gif" ? "GIF" : "Video";
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 animate-in slide-in-from-top-2">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-start gap-3">
|
||||
<div className="p-1 bg-red-500/20 rounded-full">
|
||||
<X className="w-3 h-3 text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm text-red-400 leading-relaxed">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
// Determine if we're in the compiling phase (frames done but still exporting)
|
||||
const isCompiling =
|
||||
isExporting && progress && progress.percentage >= 100 && exportFormat === "gif";
|
||||
const isFinalizing = progress?.phase === "finalizing";
|
||||
const renderProgress = progress?.renderProgress;
|
||||
|
||||
{isExporting && progress && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
<span>{isCompiling || isFinalizing ? 'Compiling' : 'Rendering Frames'}</span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{isCompiling || isFinalizing ? (
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
`${renderProgress}%`
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Processing...
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
`${progress.percentage.toFixed(0)}%`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
|
||||
{isCompiling || isFinalizing ? (
|
||||
// Show render progress if available, otherwise animated indeterminate bar
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${renderProgress}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full relative overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full w-1/3 bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)]"
|
||||
style={{
|
||||
animation: 'indeterminate 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<style>{`
|
||||
// Get status message based on phase
|
||||
const getStatusMessage = () => {
|
||||
if (error) return "Please try again";
|
||||
if (isCompiling || isFinalizing) {
|
||||
if (exportFormat === "mp4") {
|
||||
return "Finalizing video export...";
|
||||
}
|
||||
if (renderProgress !== undefined && renderProgress > 0) {
|
||||
return `Compiling GIF... ${renderProgress}%`;
|
||||
}
|
||||
return "Compiling GIF... This may take a while";
|
||||
}
|
||||
return "This may take a moment...";
|
||||
};
|
||||
|
||||
// Get title based on phase
|
||||
const getTitle = () => {
|
||||
if (error) return "Export Failed";
|
||||
if (isFinalizing && exportFormat === "mp4") return "Finalizing Video";
|
||||
if (isCompiling || isFinalizing) return "Compiling GIF";
|
||||
return `Exporting ${formatLabel}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 animate-in fade-in duration-200"
|
||||
onClick={isExporting ? undefined : onClose}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSuccess ? (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-full bg-[#34B27B]/20 flex items-center justify-center ring-1 ring-[#34B27B]/50">
|
||||
<Download className="w-6 h-6 text-[#34B27B]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
Your {formatLabel.toLowerCase()} is ready
|
||||
</span>
|
||||
{exportedFilePath && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onShowInFolder}
|
||||
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
|
||||
>
|
||||
Show in Folder
|
||||
</Button>
|
||||
)}
|
||||
{exportedFilePath && (
|
||||
<span className="text-xs text-slate-500 break-all max-w-xs mt-1">
|
||||
{exportedFilePath.split("/").pop()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isExporting ? (
|
||||
<div className="w-12 h-12 rounded-full bg-[#34B27B]/10 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 text-[#34B27B] animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
|
||||
<Download className="w-6 h-6 text-slate-200" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">{getTitle()}</span>
|
||||
<span className="text-sm text-slate-400">{getStatusMessage()}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isExporting && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="hover:bg-white/10 text-slate-400 hover:text-white rounded-full"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 animate-in slide-in-from-top-2">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-start gap-3">
|
||||
<div className="p-1 bg-red-500/20 rounded-full">
|
||||
<X className="w-3 h-3 text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm text-red-400 leading-relaxed">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExporting && progress && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
<span>{isCompiling || isFinalizing ? "Compiling" : "Rendering Frames"}</span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{isCompiling || isFinalizing ? (
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
`${renderProgress}%`
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Processing...
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
`${progress.percentage.toFixed(0)}%`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
|
||||
{isCompiling || isFinalizing ? (
|
||||
// Show render progress if available, otherwise animated indeterminate bar
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${renderProgress}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full relative overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full w-1/3 bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)]"
|
||||
style={{
|
||||
animation: "indeterminate 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<style>{`
|
||||
@keyframes indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||
{isCompiling || isFinalizing ? 'Status' : 'Format'}
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{isCompiling || isFinalizing ? 'Compiling...' : formatLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Frames</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{progress.currentFrame} / {progress.totalFrames}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||
{isCompiling || isFinalizing ? "Status" : "Format"}
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{isFinalizing && exportFormat === "mp4"
|
||||
? "Finalizing..."
|
||||
: isCompiling || isFinalizing
|
||||
? "Compiling..."
|
||||
: formatLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||
Frames
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{progress.currentFrame} / {progress.totalFrames}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onCancel && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="destructive"
|
||||
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
|
||||
>
|
||||
Cancel Export
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onCancel && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="destructive"
|
||||
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
|
||||
>
|
||||
Cancel Export
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSuccess && (
|
||||
<div className="text-center py-4 animate-in zoom-in-95">
|
||||
<p className="text-lg text-slate-200 font-medium">
|
||||
{formatLabel} saved successfully!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
{showSuccess && (
|
||||
<div className="text-center py-4 animate-in zoom-in-95">
|
||||
<p className="text-lg text-slate-200 font-medium">{formatLabel} saved successfully!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
import { Film, Image } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExportFormat } from '@/lib/exporter/types';
|
||||
import { Film, Image } from "lucide-react";
|
||||
import type { ExportFormat } from "@/lib/exporter/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FormatSelectorProps {
|
||||
selectedFormat: ExportFormat;
|
||||
onFormatChange: (format: ExportFormat) => void;
|
||||
disabled?: boolean;
|
||||
selectedFormat: ExportFormat;
|
||||
onFormatChange: (format: ExportFormat) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormatOption {
|
||||
value: ExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
value: ExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const formatOptions: FormatOption[] = [
|
||||
{
|
||||
value: 'mp4',
|
||||
label: 'MP4 Video',
|
||||
description: 'High quality video file',
|
||||
icon: <Film className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
value: 'gif',
|
||||
label: 'GIF Animation',
|
||||
description: 'Animated image for sharing',
|
||||
icon: <Image className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
value: "mp4",
|
||||
label: "MP4 Video",
|
||||
description: "High quality video file",
|
||||
icon: <Film className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
value: "gif",
|
||||
label: "GIF Animation",
|
||||
description: "Animated image for sharing",
|
||||
icon: <Image className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function FormatSelector({
|
||||
selectedFormat,
|
||||
onFormatChange,
|
||||
disabled = false,
|
||||
selectedFormat,
|
||||
onFormatChange,
|
||||
disabled = false,
|
||||
}: FormatSelectorProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{formatOptions.map((option) => {
|
||||
const isSelected = selectedFormat === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onFormatChange(option.value)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]',
|
||||
isSelected
|
||||
? 'bg-[#34B27B]/10 border-[#34B27B]/50 text-white'
|
||||
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center transition-colors',
|
||||
isSelected ? 'bg-[#34B27B]/20 text-[#34B27B]' : 'bg-white/5'
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-sm">{option.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{formatOptions.map((option) => {
|
||||
const isSelected = selectedFormat === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onFormatChange(option.value)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]",
|
||||
isSelected
|
||||
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
|
||||
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
|
||||
isSelected ? "bg-[#34B27B]/20 text-[#34B27B]" : "bg-white/5",
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-sm">{option.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,110 +1,111 @@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, type GifFrameRate, type GifSizePreset } from '@/lib/exporter/types';
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
GIF_FRAME_RATES,
|
||||
GIF_SIZE_PRESETS,
|
||||
type GifFrameRate,
|
||||
type GifSizePreset,
|
||||
} from "@/lib/exporter/types";
|
||||
|
||||
interface GifOptionsPanelProps {
|
||||
frameRate: GifFrameRate;
|
||||
onFrameRateChange: (rate: GifFrameRate) => void;
|
||||
loop: boolean;
|
||||
onLoopChange: (loop: boolean) => void;
|
||||
sizePreset: GifSizePreset;
|
||||
onSizePresetChange: (preset: GifSizePreset) => void;
|
||||
outputDimensions: { width: number; height: number };
|
||||
disabled?: boolean;
|
||||
frameRate: GifFrameRate;
|
||||
onFrameRateChange: (rate: GifFrameRate) => void;
|
||||
loop: boolean;
|
||||
onLoopChange: (loop: boolean) => void;
|
||||
sizePreset: GifSizePreset;
|
||||
onSizePresetChange: (preset: GifSizePreset) => void;
|
||||
outputDimensions: { width: number; height: number };
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function GifOptionsPanel({
|
||||
frameRate,
|
||||
onFrameRateChange,
|
||||
loop,
|
||||
onLoopChange,
|
||||
sizePreset,
|
||||
onSizePresetChange,
|
||||
outputDimensions,
|
||||
disabled = false,
|
||||
frameRate,
|
||||
onFrameRateChange,
|
||||
loop,
|
||||
onLoopChange,
|
||||
sizePreset,
|
||||
onSizePresetChange,
|
||||
outputDimensions,
|
||||
disabled = false,
|
||||
}: GifOptionsPanelProps) {
|
||||
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
|
||||
value: key as GifSizePreset,
|
||||
label: value.label,
|
||||
}));
|
||||
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
|
||||
value: key as GifSizePreset,
|
||||
label: value.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
|
||||
{/* Frame Rate */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Frame Rate
|
||||
</label>
|
||||
<Select
|
||||
value={String(frameRate)}
|
||||
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{GIF_FRAME_RATES.map((rate) => (
|
||||
<SelectItem
|
||||
key={rate.value}
|
||||
value={String(rate.value)}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{rate.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
|
||||
{/* Frame Rate */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Frame Rate
|
||||
</label>
|
||||
<Select
|
||||
value={String(frameRate)}
|
||||
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{GIF_FRAME_RATES.map((rate) => (
|
||||
<SelectItem
|
||||
key={rate.value}
|
||||
value={String(rate.value)}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{rate.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Size Preset */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Output Size
|
||||
</label>
|
||||
<Select
|
||||
value={sizePreset}
|
||||
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{sizePresetOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-slate-500">
|
||||
Output: {outputDimensions.width} × {outputDimensions.height}px
|
||||
</div>
|
||||
</div>
|
||||
{/* Size Preset */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Output Size
|
||||
</label>
|
||||
<Select
|
||||
value={sizePreset}
|
||||
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{sizePresetOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-slate-500">
|
||||
Output: {outputDimensions.width} × {outputDimensions.height}px
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loop Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
|
||||
<p className="text-xs text-slate-500">GIF will play continuously</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={loop}
|
||||
onCheckedChange={onLoopChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Loop Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
|
||||
<p className="text-xs text-slate-500">GIF will play continuously</p>
|
||||
</div>
|
||||
<Switch checked={loop} onCheckedChange={onLoopChange} disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,55 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { HelpCircle, Settings2 } from "lucide-react";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const [shortcuts, setShortcuts] = useState({
|
||||
delete: 'Ctrl + D',
|
||||
pan: 'Shift + Ctrl + Scroll',
|
||||
zoom: 'Ctrl + Scroll'
|
||||
});
|
||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
formatShortcut(['mod', 'D']),
|
||||
formatShortcut(['shift', 'mod', 'Scroll']),
|
||||
formatShortcut(['mod', 'Scroll'])
|
||||
]).then(([deleteKey, panKey, zoomKey]) => {
|
||||
setShortcuts({
|
||||
delete: deleteKey,
|
||||
pan: panKey,
|
||||
zoom: zoomKey
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<div className="relative group">
|
||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
||||
<div className="text-xs font-semibold text-slate-200 mb-2">Keyboard Shortcuts</div>
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Zoom</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Z</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Annotation</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">A</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Keyframe</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">F</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Trim</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">T</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Delete Selected</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.delete}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pan Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.pan}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Zoom Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.zoom}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pause/Play</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Space</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openConfig}
|
||||
title="Customize shortcuts"
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
Customize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{SHORTCUT_ACTIONS.map((action) => (
|
||||
<div key={action} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{formatBinding(shortcuts[action], isMac)}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
||||
{FIXED_SHORTCUTS.map((fixed) => (
|
||||
<div key={fixed.label} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{fixed.label}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{isMac
|
||||
? fixed.display
|
||||
.replace(/Ctrl/g, "⌘")
|
||||
.replace(/Shift/g, "⇧")
|
||||
.replace(/Alt/g, "⌥")
|
||||
: fixed.display}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +1,89 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface PlaybackControlsProps {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onTogglePlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onTogglePlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
|
||||
export default function PlaybackControls({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
onTogglePlayPause,
|
||||
onSeek,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
onTogglePlayPause,
|
||||
onSeek,
|
||||
}: PlaybackControlsProps) {
|
||||
function formatTime(seconds: number) {
|
||||
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
function formatTime(seconds: number) {
|
||||
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
onSeek(parseFloat(e.target.value));
|
||||
}
|
||||
function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
onSeek(parseFloat(e.target.value));
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-0.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
|
||||
<Button
|
||||
onClick={onTogglePlayPause}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full transition-all duration-200 border border-white/10",
|
||||
isPlaying
|
||||
? "bg-white/10 text-white hover:bg-white/20"
|
||||
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]"
|
||||
)}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-3.5 h-3.5 fill-current" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<span className="text-[9px] font-medium text-slate-300 tabular-nums w-[30px] text-right">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 relative h-6 flex items-center group">
|
||||
{/* Custom Track Background */}
|
||||
<div className="absolute left-0 right-0 h-0.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#34B27B] rounded-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Interactive Input */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
onChange={handleSeekChange}
|
||||
step="0.01"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
|
||||
{/* Custom Thumb (visual only, follows progress) */}
|
||||
<div
|
||||
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow-lg pointer-events-none group-hover:scale-125 transition-transform duration-100"
|
||||
style={{
|
||||
left: `${progress}%`,
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-0.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
|
||||
<Button
|
||||
onClick={onTogglePlayPause}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full transition-all duration-200 border border-white/10",
|
||||
isPlaying
|
||||
? "bg-white/10 text-white hover:bg-white/20"
|
||||
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
|
||||
)}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-3.5 h-3.5 fill-current" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<span className="text-[9px] font-medium text-slate-300 tabular-nums w-[30px] text-right">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 relative h-6 flex items-center group">
|
||||
{/* Custom Track Background */}
|
||||
<div className="absolute left-0 right-0 h-0.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#34B27B] rounded-full" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
{/* Interactive Input */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
onChange={handleSeekChange}
|
||||
step="0.01"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
|
||||
{/* Custom Thumb (visual only, follows progress) */}
|
||||
<div
|
||||
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow-lg pointer-events-none group-hover:scale-125 transition-transform duration-100"
|
||||
style={{
|
||||
left: `${progress}%`,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Keyboard, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import {
|
||||
DEFAULT_SHORTCUTS,
|
||||
FIXED_SHORTCUTS,
|
||||
findConflict,
|
||||
formatBinding,
|
||||
SHORTCUT_ACTIONS,
|
||||
SHORTCUT_LABELS,
|
||||
type ShortcutAction,
|
||||
type ShortcutBinding,
|
||||
type ShortcutConflict,
|
||||
type ShortcutsConfig,
|
||||
} from "@/lib/shortcuts";
|
||||
|
||||
const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
|
||||
|
||||
export function ShortcutsConfigDialog() {
|
||||
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
|
||||
useShortcuts();
|
||||
|
||||
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
|
||||
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
|
||||
const [conflict, setConflict] = useState<{
|
||||
forAction: ShortcutAction;
|
||||
pending: ShortcutBinding;
|
||||
conflictWith: ShortcutConflict;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConfigOpen) {
|
||||
setDraft(shortcuts);
|
||||
setCaptureFor(null);
|
||||
setConflict(null);
|
||||
}
|
||||
}, [isConfigOpen, shortcuts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!captureFor) return;
|
||||
|
||||
const handleCapture = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
setCaptureFor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (MODIFIER_KEYS.has(e.key)) return;
|
||||
|
||||
const binding: ShortcutBinding = {
|
||||
key: e.key.toLowerCase(),
|
||||
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
|
||||
...(e.shiftKey ? { shift: true } : {}),
|
||||
...(e.altKey ? { alt: true } : {}),
|
||||
};
|
||||
|
||||
const found = findConflict(binding, captureFor, draft);
|
||||
setCaptureFor(null);
|
||||
|
||||
if (found?.type === "fixed") {
|
||||
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (found?.type === "configurable") {
|
||||
setConflict({ forAction: captureFor, pending: binding, conflictWith: found });
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleCapture, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
||||
}, [captureFor, draft]);
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
||||
const { forAction, pending, conflictWith } = conflict;
|
||||
setDraft((prev: ShortcutsConfig) => ({
|
||||
...prev,
|
||||
[forAction]: pending,
|
||||
[conflictWith.action]: prev[forAction],
|
||||
}));
|
||||
setConflict(null);
|
||||
}, [conflict]);
|
||||
|
||||
const handleCancelConflict = useCallback(() => setConflict(null), []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setShortcuts(draft);
|
||||
await persistShortcuts(draft);
|
||||
toast.success("Keyboard shortcuts saved");
|
||||
closeConfig();
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setDraft({ ...DEFAULT_SHORTCUTS });
|
||||
toast.info("Reset to default shortcuts — click Save to apply");
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCaptureFor(null);
|
||||
setConflict(null);
|
||||
closeConfig();
|
||||
}, [closeConfig]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isConfigOpen}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
||||
Keyboard Shortcuts
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
Configurable
|
||||
</p>
|
||||
{SHORTCUT_ACTIONS.map((action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
const hasConflict = conflict?.forAction === action;
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? "Press Esc to cancel" : "Click to change"}
|
||||
className={[
|
||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||
isCapturing
|
||||
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
||||
: hasConflict
|
||||
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
{isCapturing ? "Press a key…" : formatBinding(draft[action], isMac)}
|
||||
</button>
|
||||
</div>
|
||||
{hasConflict && conflict?.conflictWith.type === "configurable" && (
|
||||
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
||||
<span className="text-amber-400">
|
||||
⚠ Already used by{" "}
|
||||
<strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
||||
>
|
||||
Swap
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConflict}
|
||||
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 mt-2">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
Fixed
|
||||
</p>
|
||||
{FIXED_SHORTCUTS.map(({ label, display }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-slate-400">{label}</span>
|
||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
|
||||
{display}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Click a shortcut then press the new key combination. Press{" "}
|
||||
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
|
||||
</p>
|
||||
|
||||
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white gap-1.5"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +1,125 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ArrowRight, HelpCircle, Scissors } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpCircle, Scissors, ArrowRight } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function TutorialHelp() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">How trimming works</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
|
||||
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Understanding how to cut out unwanted parts of your video.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-8">
|
||||
{/* Explanation */}
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
The Trim tool works by defining the segments you want to
|
||||
<span className="text-[#ef4444] font-bold"> remove</span>. Any part
|
||||
of the timeline that is
|
||||
<span className="text-[#ef4444] font-bold"> covered</span> by a red
|
||||
trim segment will be cut out when you export.
|
||||
</p>
|
||||
</div>
|
||||
{/* Visual Illustration */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||
Visual Example
|
||||
</h3>
|
||||
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
|
||||
{/* Background track (Kept parts) */}
|
||||
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
|
||||
{/* Solid line representing video */}
|
||||
</div>
|
||||
{/* Removed Segment 1 */}
|
||||
<div
|
||||
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
|
||||
style={{ width: "20%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
</span>
|
||||
</div>
|
||||
{/* Removed Segment 2 */}
|
||||
<div
|
||||
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
|
||||
style={{ width: "15%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
</span>
|
||||
</div>
|
||||
{/* Labels for kept parts */}
|
||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
|
||||
Kept
|
||||
</div>
|
||||
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
|
||||
Kept
|
||||
</div>
|
||||
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
|
||||
Kept
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-2">
|
||||
|
||||
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
|
||||
</div>
|
||||
{/* Result */}
|
||||
<div className="relative h-12 bg-[#000] rounded-lg border border-white/10 flex items-center justify-center gap-1 px-4 select-none">
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">
|
||||
Part 1
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">
|
||||
Part 2
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">
|
||||
Part 3
|
||||
</span>
|
||||
</div>
|
||||
<span className="absolute right-4 text-xs text-slate-400">
|
||||
Final Video
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Steps */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">
|
||||
1. Add Trim
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Press
|
||||
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
|
||||
or click the scissors icon to mark a section for removal.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">
|
||||
2. Adjust
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Drag the edges of the red region to cover exactly what you want
|
||||
to cut out.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">How trimming works</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
|
||||
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Understanding how to cut out unwanted parts of your video.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-8">
|
||||
{/* Explanation */}
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
The Trim tool works by defining the segments you want to
|
||||
<span className="text-[#ef4444] font-bold"> remove</span>. Any part of the timeline
|
||||
that is
|
||||
<span className="text-[#ef4444] font-bold"> covered</span> by a red trim segment will
|
||||
be cut out when you export.
|
||||
</p>
|
||||
</div>
|
||||
{/* Visual Illustration */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||
Visual Example
|
||||
</h3>
|
||||
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
|
||||
{/* Background track (Kept parts) */}
|
||||
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
|
||||
{/* Solid line representing video */}
|
||||
</div>
|
||||
{/* Removed Segment 1 */}
|
||||
<div
|
||||
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
|
||||
style={{ width: "20%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
</span>
|
||||
</div>
|
||||
{/* Removed Segment 2 */}
|
||||
<div
|
||||
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
|
||||
style={{ width: "15%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
</span>
|
||||
</div>
|
||||
{/* Labels for kept parts */}
|
||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-2">
|
||||
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
|
||||
</div>
|
||||
{/* Result */}
|
||||
<div className="relative h-12 bg-[#000] rounded-lg border border-white/10 flex items-center justify-center gap-1 px-4 select-none">
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">Part 1</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">Part 2</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">Part 3</span>
|
||||
</div>
|
||||
<span className="absolute right-4 text-xs text-slate-400">Final Video</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Steps */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">1. Add Trim</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Press
|
||||
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
|
||||
or click the scissors icon to mark a section for removal.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">2. Adjust</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Drag the edges of the red region to cover exactly what you want to cut out.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { default as VideoEditor } from './VideoEditor';
|
||||
export { default as VideoPlayback } from './VideoPlayback';
|
||||
export { default as PlaybackControls } from './PlaybackControls';
|
||||
export { default as TimelineEditor } from './timeline/TimelineEditor';
|
||||
export { default as SettingsPanel } from './SettingsPanel';
|
||||
export { default as PlaybackControls } from "./PlaybackControls";
|
||||
export { default as SettingsPanel } from "./SettingsPanel";
|
||||
export { default as TimelineEditor } from "./timeline/TimelineEditor";
|
||||
export { default as VideoEditor } from "./VideoEditor";
|
||||
export { default as VideoPlayback } from "./VideoPlayback";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProjectData,
|
||||
PROJECT_VERSION,
|
||||
resolveProjectMedia,
|
||||
validateProjectData,
|
||||
} from "./projectPersistence";
|
||||
|
||||
describe("projectPersistence media compatibility", () => {
|
||||
it("accepts legacy projects with a single videoPath", () => {
|
||||
const project = {
|
||||
version: 1,
|
||||
videoPath: "/tmp/screen.webm",
|
||||
editor: {},
|
||||
};
|
||||
|
||||
expect(validateProjectData(project)).toBe(true);
|
||||
expect(resolveProjectMedia(project)).toEqual({
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates version 2 projects with explicit media", () => {
|
||||
const project = createProjectData(
|
||||
{
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
webcamVideoPath: "/tmp/webcam.webm",
|
||||
},
|
||||
{
|
||||
wallpaper: "/wallpapers/wallpaper1.jpg",
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
borderRadius: 0,
|
||||
padding: 50,
|
||||
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||
zoomRegions: [],
|
||||
trimRegions: [],
|
||||
speedRegions: [],
|
||||
annotationRegions: [],
|
||||
aspectRatio: "16:9",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
gifLoop: true,
|
||||
gifSizePreset: "medium",
|
||||
},
|
||||
);
|
||||
|
||||
expect(project.version).toBe(PROJECT_VERSION);
|
||||
expect(project.media).toEqual({
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
webcamVideoPath: "/tmp/webcam.webm",
|
||||
});
|
||||
expect(validateProjectData(project)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
||||
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
|
||||
export const WALLPAPER_PATHS = Array.from(
|
||||
{ length: WALLPAPER_COUNT },
|
||||
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
|
||||
);
|
||||
|
||||
export const PROJECT_VERSION = 2;
|
||||
|
||||
export interface ProjectEditorState {
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
cropRegion: CropRegion;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions: TrimRegion[];
|
||||
speedRegions: SpeedRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
}
|
||||
|
||||
export interface EditorProjectData {
|
||||
version: number;
|
||||
media?: ProjectMedia;
|
||||
editor: ProjectEditorState;
|
||||
videoPath?: string;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function isFileUrl(value: string): boolean {
|
||||
return /^file:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment, index) => {
|
||||
if (!segment) return "";
|
||||
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export function toFileUrl(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
|
||||
// Windows drive path: C:/Users/...
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||
return `file://${encodePathSegments(`/${normalized}`, true)}`;
|
||||
}
|
||||
|
||||
// UNC path: //server/share/...
|
||||
if (normalized.startsWith("//")) {
|
||||
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
|
||||
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
|
||||
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
|
||||
}
|
||||
|
||||
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
return `file://${encodePathSegments(absolutePath)}`;
|
||||
}
|
||||
|
||||
export function fromFileUrl(fileUrl: string): string {
|
||||
const value = fileUrl.trim();
|
||||
if (!isFileUrl(value)) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
if (url.host && url.host !== "localhost") {
|
||||
return `//${url.host}${pathname}`;
|
||||
}
|
||||
|
||||
if (/^\/[a-zA-Z]:/.test(pathname)) {
|
||||
return pathname.slice(1);
|
||||
}
|
||||
|
||||
return pathname;
|
||||
} catch {
|
||||
const rawFallbackPath = value.replace(/^file:\/\//i, "");
|
||||
let fallbackPath = rawFallbackPath;
|
||||
try {
|
||||
fallbackPath = decodeURIComponent(rawFallbackPath);
|
||||
} catch {
|
||||
// Keep raw best-effort path if percent decoding fails.
|
||||
}
|
||||
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveNextId(prefix: string, ids: string[]): number {
|
||||
const max = ids.reduce((acc, id) => {
|
||||
const match = id.match(new RegExp(`^${prefix}-(\\d+)$`));
|
||||
if (!match) return acc;
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) ? Math.max(acc, value) : acc;
|
||||
}, 0);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
export function validateProjectData(candidate: unknown): candidate is EditorProjectData {
|
||||
if (!candidate || typeof candidate !== "object") return false;
|
||||
const project = candidate as Partial<EditorProjectData>;
|
||||
if (typeof project.version !== "number") return false;
|
||||
if (!resolveProjectMedia(project)) return false;
|
||||
if (!project.editor || typeof project.editor !== "object") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveProjectMedia(
|
||||
candidate: Partial<EditorProjectData> | { media?: unknown; videoPath?: unknown },
|
||||
): ProjectMedia | null {
|
||||
const media = normalizeProjectMedia(candidate.media);
|
||||
if (media) {
|
||||
return media;
|
||||
}
|
||||
|
||||
if (typeof candidate.videoPath === "string" && candidate.videoPath.trim()) {
|
||||
return { screenVideoPath: candidate.videoPath };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
||||
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
|
||||
|
||||
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
||||
? editor.zoomRegions
|
||||
.filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH,
|
||||
focus: {
|
||||
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
|
||||
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
||||
},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions)
|
||||
? editor.trimRegions
|
||||
.filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
|
||||
? editor.speedRegions
|
||||
.filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
const speed =
|
||||
region.speed === 0.25 ||
|
||||
region.speed === 0.5 ||
|
||||
region.speed === 0.75 ||
|
||||
region.speed === 1.25 ||
|
||||
region.speed === 1.5 ||
|
||||
region.speed === 1.75 ||
|
||||
region.speed === 2
|
||||
? region.speed
|
||||
: DEFAULT_PLAYBACK_SPEED;
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
speed,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions)
|
||||
? editor.annotationRegions
|
||||
.filter((region): region is AnnotationRegion =>
|
||||
Boolean(region && typeof region.id === "string"),
|
||||
)
|
||||
.map((region, index) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
type: region.type === "image" || region.type === "figure" ? region.type : "text",
|
||||
content: typeof region.content === "string" ? region.content : "",
|
||||
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
||||
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
||||
position: {
|
||||
x: clamp(
|
||||
isFiniteNumber(region.position?.x)
|
||||
? region.position.x
|
||||
: DEFAULT_ANNOTATION_POSITION.x,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
y: clamp(
|
||||
isFiniteNumber(region.position?.y)
|
||||
? region.position.y
|
||||
: DEFAULT_ANNOTATION_POSITION.y,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
},
|
||||
size: {
|
||||
width: clamp(
|
||||
isFiniteNumber(region.size?.width)
|
||||
? region.size.width
|
||||
: DEFAULT_ANNOTATION_SIZE.width,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
height: clamp(
|
||||
isFiniteNumber(region.size?.height)
|
||||
? region.size.height
|
||||
: DEFAULT_ANNOTATION_SIZE.height,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
},
|
||||
style: {
|
||||
...DEFAULT_ANNOTATION_STYLE,
|
||||
...(region.style && typeof region.style === "object" ? region.style : {}),
|
||||
},
|
||||
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
|
||||
figureData: region.figureData
|
||||
? {
|
||||
...DEFAULT_FIGURE_DATA,
|
||||
...region.figureData,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
|
||||
? editor.cropRegion.x
|
||||
: DEFAULT_CROP_REGION.x;
|
||||
const rawCropY = isFiniteNumber(editor.cropRegion?.y)
|
||||
? editor.cropRegion.y
|
||||
: DEFAULT_CROP_REGION.y;
|
||||
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width)
|
||||
? editor.cropRegion.width
|
||||
: DEFAULT_CROP_REGION.width;
|
||||
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
|
||||
? editor.cropRegion.height
|
||||
: DEFAULT_CROP_REGION.height;
|
||||
|
||||
const cropX = clamp(rawCropX, 0, 1);
|
||||
const cropY = clamp(rawCropY, 0, 1);
|
||||
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
|
||||
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
|
||||
|
||||
return {
|
||||
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
|
||||
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
||||
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
||||
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
||||
? clamp(editor.motionBlurAmount, 0, 1)
|
||||
: typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean"
|
||||
? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled
|
||||
? 0.35
|
||||
: 0
|
||||
: 0,
|
||||
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
|
||||
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
||||
cropRegion: {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
width: cropWidth,
|
||||
height: cropHeight,
|
||||
},
|
||||
zoomRegions: normalizedZoomRegions,
|
||||
trimRegions: normalizedTrimRegions,
|
||||
speedRegions: normalizedSpeedRegions,
|
||||
annotationRegions: normalizedAnnotationRegions,
|
||||
aspectRatio:
|
||||
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
|
||||
exportQuality:
|
||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
: "good",
|
||||
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
|
||||
gifFrameRate:
|
||||
editor.gifFrameRate === 15 ||
|
||||
editor.gifFrameRate === 20 ||
|
||||
editor.gifFrameRate === 25 ||
|
||||
editor.gifFrameRate === 30
|
||||
? editor.gifFrameRate
|
||||
: 15,
|
||||
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
|
||||
gifSizePreset:
|
||||
editor.gifSizePreset === "medium" ||
|
||||
editor.gifSizePreset === "large" ||
|
||||
editor.gifSizePreset === "original"
|
||||
? editor.gifSizePreset
|
||||
: "medium",
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectData(
|
||||
media: ProjectMedia,
|
||||
editor: ProjectEditorState,
|
||||
): EditorProjectData {
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
media,
|
||||
editor,
|
||||
};
|
||||
}
|
||||
@@ -1,120 +1,171 @@
|
||||
import { useItem } from "dnd-timeline";
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { useItem } from "dnd-timeline";
|
||||
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ZoomIn, Scissors, MessageSquare } from "lucide-react";
|
||||
import glassStyles from "./ItemGlass.module.css";
|
||||
|
||||
interface ItemProps {
|
||||
id: string;
|
||||
span: Span;
|
||||
rowId: string;
|
||||
children: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
variant?: 'zoom' | 'trim' | 'annotation';
|
||||
id: string;
|
||||
span: Span;
|
||||
rowId: string;
|
||||
children: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
speedValue?: number;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed";
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
const ZOOM_LABELS: Record<number, string> = {
|
||||
1: "1.25×",
|
||||
2: "1.5×",
|
||||
3: "1.8×",
|
||||
4: "2.2×",
|
||||
5: "3.5×",
|
||||
6: "5×",
|
||||
1: "1.25×",
|
||||
2: "1.5×",
|
||||
3: "1.8×",
|
||||
4: "2.2×",
|
||||
5: "3.5×",
|
||||
6: "5×",
|
||||
};
|
||||
|
||||
export default function Item({
|
||||
id,
|
||||
span,
|
||||
rowId,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
variant = 'zoom',
|
||||
children
|
||||
function formatMs(ms: number): string {
|
||||
const totalSeconds = ms / 1000;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function Item({
|
||||
id,
|
||||
span,
|
||||
rowId,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
speedValue,
|
||||
variant = "zoom",
|
||||
children,
|
||||
}: ItemProps) {
|
||||
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
||||
id,
|
||||
span,
|
||||
data: { rowId },
|
||||
});
|
||||
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
||||
id,
|
||||
span,
|
||||
data: { rowId },
|
||||
});
|
||||
|
||||
const isZoom = variant === 'zoom';
|
||||
const isTrim = variant === 'trim';
|
||||
|
||||
const glassClass = isZoom
|
||||
? glassStyles.glassGreen
|
||||
: isTrim
|
||||
? glassStyles.glassRed
|
||||
: glassStyles.glassYellow;
|
||||
|
||||
const endCapColor = isZoom
|
||||
? '#21916A'
|
||||
: isTrim
|
||||
? '#ef4444'
|
||||
: '#B4A046';
|
||||
const isZoom = variant === "zoom";
|
||||
const isTrim = variant === "trim";
|
||||
const isSpeed = variant === "speed";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={itemStyle}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onPointerDownCapture={() => onSelect?.()}
|
||||
className="group"
|
||||
>
|
||||
<div style={itemContentStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
glassClass,
|
||||
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
|
||||
isSelected && glassStyles.selected
|
||||
)}
|
||||
style={{ height: 40, color: '#fff' }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
|
||||
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
|
||||
title="Resize left"
|
||||
/>
|
||||
<div
|
||||
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
|
||||
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
|
||||
title="Resize right"
|
||||
/>
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex items-center gap-1.5 text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none">
|
||||
{isZoom ? (
|
||||
<>
|
||||
<ZoomIn className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-semibold tracking-tight">
|
||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
</span>
|
||||
</>
|
||||
) : isTrim ? (
|
||||
<>
|
||||
<Scissors className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-semibold tracking-tight">
|
||||
Trim
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-semibold tracking-tight">
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const glassClass = isZoom
|
||||
? glassStyles.glassGreen
|
||||
: isTrim
|
||||
? glassStyles.glassRed
|
||||
: isSpeed
|
||||
? glassStyles.glassAmber
|
||||
: glassStyles.glassYellow;
|
||||
|
||||
const endCapColor = isZoom ? "#21916A" : isTrim ? "#ef4444" : isSpeed ? "#d97706" : "#B4A046";
|
||||
|
||||
const timeLabel = useMemo(
|
||||
() => `${formatMs(span.start)} – ${formatMs(span.end)}`,
|
||||
[span.start, span.end],
|
||||
);
|
||||
|
||||
// Minimum clickable width on the outer wrapper.
|
||||
// Kept small (6px) so items visually distinguish their real positions;
|
||||
// users should zoom in to interact with sub-second items precisely.
|
||||
const MIN_ITEM_PX = 6;
|
||||
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={safeItemStyle}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onPointerDownCapture={() => onSelect?.()}
|
||||
className="group"
|
||||
>
|
||||
<div style={{ ...itemContentStyle, minWidth: 24 }}>
|
||||
<div
|
||||
className={cn(
|
||||
glassClass,
|
||||
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
|
||||
isSelected && glassStyles.selected,
|
||||
)}
|
||||
style={{ height: 40, color: "#fff", minWidth: 24 }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
|
||||
style={{
|
||||
cursor: "col-resize",
|
||||
pointerEvents: "auto",
|
||||
width: 8,
|
||||
opacity: 0.9,
|
||||
background: endCapColor,
|
||||
}}
|
||||
title="Resize left"
|
||||
/>
|
||||
<div
|
||||
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
|
||||
style={{
|
||||
cursor: "col-resize",
|
||||
pointerEvents: "auto",
|
||||
width: 8,
|
||||
opacity: 0.9,
|
||||
background: endCapColor,
|
||||
}}
|
||||
title="Resize right"
|
||||
/>
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isZoom ? (
|
||||
<>
|
||||
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
</span>
|
||||
</>
|
||||
) : isTrim ? (
|
||||
<>
|
||||
<Scissors className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
Trim
|
||||
</span>
|
||||
</>
|
||||
) : isSpeed ? (
|
||||
<>
|
||||
<Gauge className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{speedValue !== undefined ? `${speedValue}×` : "Speed"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${
|
||||
isSelected ? "opacity-60" : "opacity-0 group-hover:opacity-40"
|
||||
}`}
|
||||
>
|
||||
{timeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,90 +1,126 @@
|
||||
.glassGreen {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(52, 178, 123, 0.15);
|
||||
border: 1px solid rgba(52, 178, 123, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(52, 178, 123, 0.15);
|
||||
border: 1px solid rgba(52, 178, 123, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassGreen:hover {
|
||||
background: rgba(52, 178, 123, 0.25);
|
||||
border-color: rgba(52, 178, 123, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
|
||||
background: rgba(52, 178, 123, 0.25);
|
||||
border-color: rgba(52, 178, 123, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
|
||||
}
|
||||
|
||||
.glassGreen.selected {
|
||||
background: rgba(52, 178, 123, 0.35);
|
||||
border-color: #34B27B;
|
||||
box-shadow: 0 0 0 1px #34B27B, 0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
|
||||
z-index: 10;
|
||||
background: rgba(52, 178, 123, 0.35);
|
||||
border-color: #34b27b;
|
||||
box-shadow:
|
||||
0 0 0 1px #34b27b,
|
||||
0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassRed {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassRed:hover {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(239, 68, 68, 0.2) inset;
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(239, 68, 68, 0.2) inset;
|
||||
}
|
||||
|
||||
.glassRed.selected {
|
||||
background: rgba(239, 68, 68, 0.35);
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 1px #ef4444, 0 4px 20px 0 rgba(239, 68, 68, 0.3) inset;
|
||||
z-index: 10;
|
||||
background: rgba(239, 68, 68, 0.35);
|
||||
border-color: #ef4444;
|
||||
box-shadow:
|
||||
0 0 0 1px #ef4444,
|
||||
0 4px 20px 0 rgba(239, 68, 68, 0.3) inset;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassYellow {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(180, 160, 70, 0.15);
|
||||
border: 1px solid rgba(180, 160, 70, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(180, 160, 70, 0.15);
|
||||
border: 1px solid rgba(180, 160, 70, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassYellow:hover {
|
||||
background: rgba(180, 160, 70, 0.25);
|
||||
border-color: rgba(180, 160, 70, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(180, 160, 70, 0.2) inset;
|
||||
background: rgba(180, 160, 70, 0.25);
|
||||
border-color: rgba(180, 160, 70, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(180, 160, 70, 0.2) inset;
|
||||
}
|
||||
|
||||
.glassYellow.selected {
|
||||
background: rgba(180, 160, 70, 0.35);
|
||||
border-color: #B4A046;
|
||||
box-shadow: 0 0 0 1px #B4A046, 0 4px 20px 0 rgba(180, 160, 70, 0.3) inset;
|
||||
z-index: 10;
|
||||
background: rgba(180, 160, 70, 0.35);
|
||||
border-color: #b4a046;
|
||||
box-shadow:
|
||||
0 0 0 1px #b4a046,
|
||||
0 4px 20px 0 rgba(180, 160, 70, 0.3) inset;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassAmber {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassAmber:hover {
|
||||
background: rgba(245, 158, 11, 0.25);
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(245, 158, 11, 0.2) inset;
|
||||
}
|
||||
|
||||
.glassAmber.selected {
|
||||
background: rgba(245, 158, 11, 0.35);
|
||||
border-color: #f59e0b;
|
||||
box-shadow:
|
||||
0 0 0 1px #f59e0b,
|
||||
0 4px 20px 0 rgba(245, 158, 11, 0.3) inset;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoomEndCap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, width 0.2s;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
opacity: 0.45;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
width 0.2s;
|
||||
}
|
||||
|
||||
.glassGreen:hover .zoomEndCap,
|
||||
@@ -92,20 +128,22 @@
|
||||
.glassRed:hover .zoomEndCap,
|
||||
.glassRed.selected .zoomEndCap,
|
||||
.glassYellow:hover .zoomEndCap,
|
||||
.glassYellow.selected .zoomEndCap {
|
||||
opacity: 1;
|
||||
.glassYellow.selected .zoomEndCap,
|
||||
.glassAmber:hover .zoomEndCap,
|
||||
.glassAmber.selected .zoomEndCap {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.zoomEndCap.left {
|
||||
left: 0;
|
||||
cursor: ew-resize;
|
||||
border-top-left-radius: 7px;
|
||||
border-bottom-left-radius: 7px;
|
||||
left: 0;
|
||||
cursor: ew-resize;
|
||||
border-top-left-radius: 7px;
|
||||
border-bottom-left-radius: 7px;
|
||||
}
|
||||
|
||||
.zoomEndCap.right {
|
||||
right: 0;
|
||||
cursor: ew-resize;
|
||||
border-top-right-radius: 7px;
|
||||
border-bottom-right-radius: 7px;
|
||||
}
|
||||
right: 0;
|
||||
cursor: ew-resize;
|
||||
border-top-right-radius: 7px;
|
||||
border-bottom-right-radius: 7px;
|
||||
}
|
||||
|
||||
@@ -1,50 +1,114 @@
|
||||
import React from "react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface Keyframe {
|
||||
id: string;
|
||||
time: number;
|
||||
id: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
interface KeyframeMarkersProps {
|
||||
keyframes: Keyframe[];
|
||||
selectedKeyframeId: string | null;
|
||||
setSelectedKeyframeId: (id: string | null) => void;
|
||||
keyframes: Keyframe[];
|
||||
selectedKeyframeId: string | null;
|
||||
setSelectedKeyframeId: (id: string | null) => void;
|
||||
onKeyframeMove: (id: string, newTime: number) => void;
|
||||
videoDurationMs: number;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({ keyframes, selectedKeyframeId, setSelectedKeyframeId }) => {
|
||||
const { sidebarWidth, range, valueToPixels } = useTimelineContext();
|
||||
return (
|
||||
<>
|
||||
{keyframes.map(kf => {
|
||||
const offset = valueToPixels(kf.time - range.start);
|
||||
const isSelected = kf.id === selectedKeyframeId;
|
||||
return (
|
||||
<div
|
||||
key={kf.id}
|
||||
className={`absolute top-8 cursor-pointer ${isSelected ? 'ring-2 ring-[#34B27B]' : ''}`}
|
||||
style={{ left: `${sidebarWidth + offset - 8}px`, zIndex: 40 }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setSelectedKeyframeId(kf.id);
|
||||
}}
|
||||
title={`Keyframe @ ${kf.time}ms`}
|
||||
>
|
||||
<div style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: '#ffe100ff',
|
||||
transform: 'rotate(45deg)',
|
||||
border: 'none',
|
||||
|
||||
opacity: isSelected ? 1 : 0.6,
|
||||
transition: 'opacity 0.15s',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({
|
||||
keyframes,
|
||||
selectedKeyframeId,
|
||||
setSelectedKeyframeId,
|
||||
onKeyframeMove,
|
||||
videoDurationMs,
|
||||
timelineRef,
|
||||
}) => {
|
||||
const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||
const [draggingKeyframeId, setDraggingKeyframeId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingKeyframeId) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!timelineRef.current) return;
|
||||
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
|
||||
// Update the keyframe position in real-time
|
||||
onKeyframeMove(draggingKeyframeId, absoluteMs);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDraggingKeyframeId(null);
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "ew-resize";
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
}, [
|
||||
draggingKeyframeId,
|
||||
onKeyframeMove,
|
||||
timelineRef,
|
||||
sidebarWidth,
|
||||
range.start,
|
||||
videoDurationMs,
|
||||
pixelsToValue,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{keyframes.map((kf) => {
|
||||
const offset = valueToPixels(kf.time - range.start);
|
||||
const isSelected = kf.id === selectedKeyframeId;
|
||||
const isDragging = kf.id === draggingKeyframeId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kf.id}
|
||||
className={`absolute top-8 cursor-grab active:cursor-grabbing ${isSelected ? "ring-2 ring-[#34B27B]" : ""}`}
|
||||
style={{
|
||||
left: `${sidebarWidth + offset - 8}px`,
|
||||
zIndex: isDragging ? 50 : 40,
|
||||
transition: isDragging ? "none" : "left 0.1s ease-out",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedKeyframeId(kf.id);
|
||||
setDraggingKeyframeId(kf.id);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedKeyframeId(kf.id);
|
||||
}}
|
||||
title={`Keyframe @ ${Math.round(kf.time)}ms (drag to move, Delete/Backspace to remove)`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
background: "#ffe100ff",
|
||||
transform: "rotate(45deg)",
|
||||
border: "none",
|
||||
opacity: isSelected ? 1 : 0.6,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyframeMarkers;
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import { useRow } from "dnd-timeline";
|
||||
import type { RowDefinition } from "dnd-timeline";
|
||||
import { useRow } from "dnd-timeline";
|
||||
|
||||
interface RowProps extends RowDefinition {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
isEmpty?: boolean;
|
||||
labelColor?: string;
|
||||
}
|
||||
|
||||
export default function Row({ id, children }: RowProps) {
|
||||
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
|
||||
export default function Row({ id, children, label, hint, isEmpty, labelColor = "#666" }: RowProps) {
|
||||
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-[#18181b] bg-[#18181b]"
|
||||
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
|
||||
>
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="border-b border-[#18181b] bg-[#18181b] relative"
|
||||
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
|
||||
>
|
||||
{label && (
|
||||
<div
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
|
||||
style={{ color: labelColor, writingMode: "horizontal-tb" }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && hint && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
|
||||
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SubrowProps {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Subrow({ children }: SubrowProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center min-h-[32px] gap-1 px-2 py-0.5 bg-[#23232a] rounded-md text-slate-300")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center min-h-[32px] gap-1 px-2 py-0.5 bg-[#23232a] rounded-md text-slate-300",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,163 +1,317 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import type {
|
||||
DragEndEvent,
|
||||
DragMoveEvent,
|
||||
DragStartEvent,
|
||||
Range,
|
||||
ResizeEndEvent,
|
||||
ResizeMoveEvent,
|
||||
Span,
|
||||
} from "dnd-timeline";
|
||||
import { TimelineContext } from "dnd-timeline";
|
||||
import type { DragEndEvent, Range, ResizeEndEvent, Span } from "dnd-timeline";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
interface TimelineWrapperProps {
|
||||
children: ReactNode;
|
||||
range: Range;
|
||||
videoDuration: number;
|
||||
hasOverlap: (newSpan: Span, excludeId?: string) => boolean;
|
||||
onRangeChange: Dispatch<SetStateAction<Range>>;
|
||||
minItemDurationMs: number;
|
||||
minVisibleRangeMs: number;
|
||||
gridSizeMs: number;
|
||||
onItemSpanChange: (id: string, span: Span) => void;
|
||||
children: ReactNode;
|
||||
range: Range;
|
||||
videoDuration: number;
|
||||
hasOverlap: (newSpan: Span, excludeId?: string) => boolean;
|
||||
onRangeChange: Dispatch<SetStateAction<Range>>;
|
||||
minItemDurationMs: number;
|
||||
minVisibleRangeMs: number;
|
||||
gridSizeMs?: number;
|
||||
onItemSpanChange: (id: string, span: Span) => void;
|
||||
allRegionSpans?: { id: string; start: number; end: number }[];
|
||||
}
|
||||
|
||||
export default function TimelineWrapper({
|
||||
children,
|
||||
range,
|
||||
videoDuration,
|
||||
hasOverlap,
|
||||
onRangeChange,
|
||||
minItemDurationMs,
|
||||
minVisibleRangeMs,
|
||||
gridSizeMs: _gridSizeMs,
|
||||
onItemSpanChange,
|
||||
children,
|
||||
range,
|
||||
videoDuration,
|
||||
hasOverlap,
|
||||
onRangeChange,
|
||||
minItemDurationMs,
|
||||
minVisibleRangeMs,
|
||||
gridSizeMs: _gridSizeMs,
|
||||
onItemSpanChange,
|
||||
allRegionSpans = [],
|
||||
}: TimelineWrapperProps) {
|
||||
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
|
||||
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
|
||||
|
||||
const clampSpanToBounds = useCallback(
|
||||
(span: Span): Span => {
|
||||
const rawDuration = Math.max(span.end - span.start, 0);
|
||||
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
|
||||
const clampSpanToBounds = useCallback(
|
||||
(span: Span): Span => {
|
||||
const rawDuration = Math.max(span.end - span.start, 0);
|
||||
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
|
||||
|
||||
if (totalMs === 0) {
|
||||
const minDuration = Math.max(minItemDurationMs, 1);
|
||||
const duration = Math.max(rawDuration, minDuration);
|
||||
const start = Math.max(0, normalizedStart);
|
||||
return {
|
||||
start,
|
||||
end: start + duration,
|
||||
};
|
||||
}
|
||||
if (totalMs === 0) {
|
||||
const minDuration = Math.max(minItemDurationMs, 1);
|
||||
const duration = Math.max(rawDuration, minDuration);
|
||||
const start = Math.max(0, normalizedStart);
|
||||
return {
|
||||
start,
|
||||
end: start + duration,
|
||||
};
|
||||
}
|
||||
|
||||
const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs);
|
||||
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
|
||||
const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs);
|
||||
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
|
||||
|
||||
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
|
||||
const end = start + duration;
|
||||
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
|
||||
const end = start + duration;
|
||||
|
||||
return { start, end };
|
||||
},
|
||||
[minItemDurationMs, totalMs],
|
||||
);
|
||||
return { start, end };
|
||||
},
|
||||
[minItemDurationMs, totalMs],
|
||||
);
|
||||
|
||||
const clampRange = useCallback(
|
||||
(candidate: Range): Range => {
|
||||
if (totalMs === 0) {
|
||||
const minSpan = Math.max(minVisibleRangeMs, 1);
|
||||
const span = Math.max(candidate.end - candidate.start, minSpan);
|
||||
const start = Math.max(0, Math.min(candidate.start, candidate.end - span));
|
||||
return { start, end: start + span };
|
||||
}
|
||||
const clampRange = useCallback(
|
||||
(candidate: Range): Range => {
|
||||
if (totalMs === 0) {
|
||||
const minSpan = Math.max(minVisibleRangeMs, 1);
|
||||
const span = Math.max(candidate.end - candidate.start, minSpan);
|
||||
const start = Math.max(0, Math.min(candidate.start, candidate.end - span));
|
||||
return { start, end: start + span };
|
||||
}
|
||||
|
||||
const rawStart = Math.max(0, candidate.start);
|
||||
const rawEnd = candidate.end;
|
||||
const clampedEnd = Math.min(rawEnd, totalMs);
|
||||
|
||||
const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs);
|
||||
const desiredSpan = clampedEnd - rawStart;
|
||||
const span = Math.min(Math.max(desiredSpan, minSpan), totalMs);
|
||||
|
||||
let finalStart = rawStart;
|
||||
let finalEnd = finalStart + span;
|
||||
|
||||
if (finalEnd > totalMs) {
|
||||
finalEnd = totalMs;
|
||||
finalStart = Math.max(0, finalEnd - span);
|
||||
}
|
||||
const rawStart = Math.max(0, candidate.start);
|
||||
const rawEnd = candidate.end;
|
||||
const clampedEnd = Math.min(rawEnd, totalMs);
|
||||
|
||||
return { start: finalStart, end: finalEnd };
|
||||
},
|
||||
[minVisibleRangeMs, totalMs],
|
||||
);
|
||||
const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs);
|
||||
const desiredSpan = clampedEnd - rawStart;
|
||||
const span = Math.min(Math.max(desiredSpan, minSpan), totalMs);
|
||||
|
||||
const onResizeEnd = useCallback(
|
||||
(event: ResizeEndEvent) => {
|
||||
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
|
||||
if (!updatedSpan) return;
|
||||
|
||||
const activeItemId = event.active.id as string;
|
||||
const clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
let finalStart = rawStart;
|
||||
let finalEnd = finalStart + span;
|
||||
|
||||
if (clampedSpan.end - clampedSpan.start < Math.min(minItemDurationMs, totalMs || minItemDurationMs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
return;
|
||||
}
|
||||
if (finalEnd > totalMs) {
|
||||
finalEnd = totalMs;
|
||||
finalStart = Math.max(0, finalEnd - span);
|
||||
}
|
||||
|
||||
onItemSpanChange(activeItemId, clampedSpan);
|
||||
},
|
||||
[clampSpanToBounds, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs]
|
||||
);
|
||||
return { start: finalStart, end: finalEnd };
|
||||
},
|
||||
[minVisibleRangeMs, totalMs],
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeRowId = event.over?.id as string;
|
||||
const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event);
|
||||
if (!updatedSpan || !activeRowId) return;
|
||||
|
||||
const activeItemId = event.active.id as string;
|
||||
const clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
return;
|
||||
}
|
||||
// When a span overlaps neighbours, clamp it to the nearest boundary
|
||||
const clampToNeighbours = useCallback(
|
||||
(span: Span, activeItemId: string): Span => {
|
||||
const siblings = allRegionSpans.filter((r) => r.id !== activeItemId);
|
||||
let { start, end } = span;
|
||||
|
||||
onItemSpanChange(activeItemId, clampedSpan);
|
||||
},
|
||||
[clampSpanToBounds, hasOverlap, onItemSpanChange]
|
||||
);
|
||||
for (const r of siblings) {
|
||||
// Span's right edge crossed into a region to the right
|
||||
if (end > r.start && start < r.start) {
|
||||
end = r.start;
|
||||
}
|
||||
// Span's left edge crossed into a region to the left
|
||||
if (start < r.end && end > r.end) {
|
||||
start = r.end;
|
||||
}
|
||||
}
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
(updater: (previous: Range) => Range) => {
|
||||
onRangeChange((prev) => {
|
||||
const normalized = totalMs > 0 ? clampRange(prev) : prev;
|
||||
const desired = updater(normalized);
|
||||
|
||||
if (totalMs > 0) {
|
||||
const clamped = clampRange(desired);
|
||||
|
||||
if (clamped.end > totalMs) {
|
||||
const span = Math.min(clamped.end - clamped.start, totalMs);
|
||||
return {
|
||||
start: Math.max(0, totalMs - span),
|
||||
end: totalMs,
|
||||
};
|
||||
}
|
||||
|
||||
return clamped;
|
||||
}
|
||||
|
||||
return desired;
|
||||
});
|
||||
},
|
||||
[clampRange, onRangeChange, totalMs],
|
||||
);
|
||||
// Ensure minimum duration after clamping
|
||||
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
|
||||
if (end - start < minDur) {
|
||||
// Try extending in the direction that has room
|
||||
if (end + minDur - (end - start) <= totalMs) {
|
||||
end = start + minDur;
|
||||
} else {
|
||||
start = end - minDur;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContext
|
||||
range={range}
|
||||
onRangeChanged={handleRangeChange}
|
||||
onResizeEnd={onResizeEnd}
|
||||
onDragEnd={onDragEnd}
|
||||
autoScroll={{ enabled: false }}
|
||||
>
|
||||
{children}
|
||||
</TimelineContext>
|
||||
);
|
||||
}
|
||||
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
|
||||
},
|
||||
[allRegionSpans, minItemDurationMs, totalMs],
|
||||
);
|
||||
|
||||
const onResizeEnd = useCallback(
|
||||
(event: ResizeEndEvent) => {
|
||||
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
|
||||
if (!updatedSpan) return;
|
||||
|
||||
const activeItemId = event.active.id as string;
|
||||
let clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
|
||||
const effectiveMinDuration =
|
||||
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
|
||||
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp to neighbour boundaries instead of rejecting
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
|
||||
// If still overlapping after clamping, fall back to original position
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onItemSpanChange(activeItemId, clampedSpan);
|
||||
},
|
||||
[
|
||||
clampSpanToBounds,
|
||||
clampToNeighbours,
|
||||
hasOverlap,
|
||||
minItemDurationMs,
|
||||
onItemSpanChange,
|
||||
totalMs,
|
||||
],
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeRowId = event.over?.id as string;
|
||||
const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event);
|
||||
if (!updatedSpan || !activeRowId) return;
|
||||
|
||||
const activeItemId = event.active.id as string;
|
||||
let clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
|
||||
// Clamp to neighbour boundaries instead of rejecting
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onItemSpanChange(activeItemId, clampedSpan);
|
||||
},
|
||||
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange],
|
||||
);
|
||||
|
||||
// Drag/resize tooltip (direct DOM updates, no re-renders)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const formatTooltipMs = useCallback((ms: number) => {
|
||||
const s = ms / 1000;
|
||||
const min = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
|
||||
}, []);
|
||||
|
||||
const showTooltip = useCallback(
|
||||
(span: { start: number; end: number } | null, screenX?: number) => {
|
||||
const el = tooltipRef.current;
|
||||
if (!el) return;
|
||||
if (!span) {
|
||||
el.style.opacity = "0";
|
||||
return;
|
||||
}
|
||||
el.textContent = `${formatTooltipMs(span.start)} – ${formatTooltipMs(span.end)}`;
|
||||
el.style.opacity = "1";
|
||||
if (screenX !== undefined) {
|
||||
const parent = el.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(screenX - rect.left, rect.width - 100));
|
||||
el.style.left = `${x}px`;
|
||||
}
|
||||
}
|
||||
},
|
||||
[formatTooltipMs],
|
||||
);
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const span = event.active.data.current.getSpanFromDragEvent?.(event);
|
||||
if (span) showTooltip(span);
|
||||
},
|
||||
[showTooltip],
|
||||
);
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(event: DragMoveEvent) => {
|
||||
const span = event.active.data.current.getSpanFromDragEvent?.(event);
|
||||
const screenX =
|
||||
event.activatorEvent && "clientX" in event.activatorEvent
|
||||
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
|
||||
: undefined;
|
||||
if (span) showTooltip(span, screenX);
|
||||
},
|
||||
[showTooltip],
|
||||
);
|
||||
|
||||
const onResizeMove = useCallback(
|
||||
(event: ResizeMoveEvent) => {
|
||||
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
|
||||
const screenX =
|
||||
event.activatorEvent && "clientX" in event.activatorEvent
|
||||
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
|
||||
: undefined;
|
||||
if (span) showTooltip(span, screenX);
|
||||
},
|
||||
[showTooltip],
|
||||
);
|
||||
|
||||
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
|
||||
|
||||
const onResizeEndWithTooltip = useCallback(
|
||||
(event: ResizeEndEvent) => {
|
||||
hideTooltip();
|
||||
onResizeEnd(event);
|
||||
},
|
||||
[hideTooltip, onResizeEnd],
|
||||
);
|
||||
|
||||
const onDragEndWithTooltip = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
hideTooltip();
|
||||
onDragEnd(event);
|
||||
},
|
||||
[hideTooltip, onDragEnd],
|
||||
);
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
(updater: (previous: Range) => Range) => {
|
||||
onRangeChange((prev) => {
|
||||
const normalized = totalMs > 0 ? clampRange(prev) : prev;
|
||||
const desired = updater(normalized);
|
||||
|
||||
if (totalMs > 0) {
|
||||
const clamped = clampRange(desired);
|
||||
|
||||
if (clamped.end > totalMs) {
|
||||
const span = Math.min(clamped.end - clamped.start, totalMs);
|
||||
return {
|
||||
start: Math.max(0, totalMs - span),
|
||||
end: totalMs,
|
||||
};
|
||||
}
|
||||
|
||||
return clamped;
|
||||
}
|
||||
|
||||
return desired;
|
||||
});
|
||||
},
|
||||
[clampRange, onRangeChange, totalMs],
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineContext
|
||||
range={range}
|
||||
onRangeChanged={handleRangeChange}
|
||||
onResizeEnd={onResizeEndWithTooltip}
|
||||
onResizeMove={onResizeMove}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEndWithTooltip}
|
||||
autoScroll={{ enabled: false }}
|
||||
>
|
||||
<div className="relative">
|
||||
{children}
|
||||
{/* Floating tooltip shown during drag/resize */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="absolute top-1 pointer-events-none z-[60] px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg"
|
||||
style={{ opacity: 0, transition: "opacity 0.1s" }}
|
||||
/>
|
||||
</div>
|
||||
</TimelineContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
|
||||
export const MIN_DWELL_DURATION_MS = 450;
|
||||
export const MAX_DWELL_DURATION_MS = 2600;
|
||||
export const DWELL_MOVE_THRESHOLD = 0.02;
|
||||
|
||||
export interface ZoomDwellCandidate {
|
||||
centerTimeMs: number;
|
||||
focus: ZoomFocus;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
function normalizeTelemetrySample(
|
||||
sample: CursorTelemetryPoint,
|
||||
totalMs: number,
|
||||
): CursorTelemetryPoint {
|
||||
return {
|
||||
timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)),
|
||||
cx: Math.max(0, Math.min(sample.cx, 1)),
|
||||
cy: Math.max(0, Math.min(sample.cy, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCursorTelemetry(
|
||||
telemetry: CursorTelemetryPoint[],
|
||||
totalMs: number,
|
||||
): CursorTelemetryPoint[] {
|
||||
return [...telemetry]
|
||||
.filter(
|
||||
(sample) =>
|
||||
Number.isFinite(sample.timeMs) && Number.isFinite(sample.cx) && Number.isFinite(sample.cy),
|
||||
)
|
||||
.sort((a, b) => a.timeMs - b.timeMs)
|
||||
.map((sample) => normalizeTelemetrySample(sample, totalMs));
|
||||
}
|
||||
|
||||
export function detectZoomDwellCandidates(samples: CursorTelemetryPoint[]): ZoomDwellCandidate[] {
|
||||
if (samples.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dwellCandidates: ZoomDwellCandidate[] = [];
|
||||
let runStart = 0;
|
||||
|
||||
const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => {
|
||||
if (endIndexExclusive - startIndex < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = samples[startIndex];
|
||||
const end = samples[endIndexExclusive - 1];
|
||||
const runDuration = end.timeMs - start.timeMs;
|
||||
if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runSamples = samples.slice(startIndex, endIndexExclusive);
|
||||
const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length;
|
||||
const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length;
|
||||
|
||||
dwellCandidates.push({
|
||||
centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2),
|
||||
focus: { cx: avgCx, cy: avgCy },
|
||||
strength: runDuration,
|
||||
});
|
||||
};
|
||||
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const prev = samples[index - 1];
|
||||
const curr = samples[index];
|
||||
const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy);
|
||||
|
||||
if (distance > DWELL_MOVE_THRESHOLD) {
|
||||
pushRunIfDwell(runStart, index);
|
||||
runStart = index;
|
||||
}
|
||||
}
|
||||
pushRunIfDwell(runStart, samples.length);
|
||||
|
||||
return dwellCandidates;
|
||||
}
|
||||
@@ -1,132 +1,165 @@
|
||||
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export interface ZoomFocus {
|
||||
cx: number; // normalized horizontal center (0-1)
|
||||
cy: number; // normalized vertical center (0-1)
|
||||
cx: number; // normalized horizontal center (0-1)
|
||||
cy: number; // normalized vertical center (0-1)
|
||||
}
|
||||
|
||||
export interface ZoomRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
depth: ZoomDepth;
|
||||
focus: ZoomFocus;
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
depth: ZoomDepth;
|
||||
focus: ZoomFocus;
|
||||
}
|
||||
|
||||
export interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
export interface TrimRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export type AnnotationType = 'text' | 'image' | 'figure';
|
||||
export type AnnotationType = "text" | "image" | "figure";
|
||||
|
||||
export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left';
|
||||
export type ArrowDirection =
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right"
|
||||
| "up-right"
|
||||
| "up-left"
|
||||
| "down-right"
|
||||
| "down-left";
|
||||
|
||||
export interface FigureData {
|
||||
arrowDirection: ArrowDirection;
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
arrowDirection: ArrowDirection;
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface AnnotationSize {
|
||||
width: number;
|
||||
height: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface AnnotationTextStyle {
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
fontSize: number; // pixels
|
||||
fontFamily: string;
|
||||
fontWeight: 'normal' | 'bold';
|
||||
fontStyle: 'normal' | 'italic';
|
||||
textDecoration: 'none' | 'underline';
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
fontSize: number; // pixels
|
||||
fontFamily: string;
|
||||
fontWeight: "normal" | "bold";
|
||||
fontStyle: "normal" | "italic";
|
||||
textDecoration: "none" | "underline";
|
||||
textAlign: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
export interface AnnotationRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
type: AnnotationType;
|
||||
content: string; // Legacy - still used for current type
|
||||
textContent?: string; // Separate storage for text
|
||||
imageContent?: string; // Separate storage for image data URL
|
||||
position: AnnotationPosition;
|
||||
size: AnnotationSize;
|
||||
style: AnnotationTextStyle;
|
||||
zIndex: number;
|
||||
figureData?: FigureData;
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
type: AnnotationType;
|
||||
content: string; // Legacy - still used for current type
|
||||
textContent?: string; // Separate storage for text
|
||||
imageContent?: string; // Separate storage for image data URL
|
||||
position: AnnotationPosition;
|
||||
size: AnnotationSize;
|
||||
style: AnnotationTextStyle;
|
||||
zIndex: number;
|
||||
figureData?: FigureData;
|
||||
}
|
||||
|
||||
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
|
||||
x: 50,
|
||||
y: 50,
|
||||
x: 50,
|
||||
y: 50,
|
||||
};
|
||||
|
||||
export const DEFAULT_ANNOTATION_SIZE: AnnotationSize = {
|
||||
width: 30,
|
||||
height: 20,
|
||||
width: 30,
|
||||
height: 20,
|
||||
};
|
||||
|
||||
export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
|
||||
color: '#ffffff',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 32,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 'bold',
|
||||
fontStyle: 'normal',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center',
|
||||
color: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
fontSize: 32,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
arrowDirection: 'right',
|
||||
color: '#34B27B',
|
||||
strokeWidth: 4,
|
||||
arrowDirection: "right",
|
||||
color: "#34B27B",
|
||||
strokeWidth: 4,
|
||||
};
|
||||
|
||||
|
||||
|
||||
export interface CropRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
|
||||
|
||||
export interface SpeedRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
speed: PlaybackSpeed;
|
||||
}
|
||||
|
||||
export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
|
||||
{ speed: 0.25, label: "0.25×" },
|
||||
{ speed: 0.5, label: "0.5×" },
|
||||
{ speed: 0.75, label: "0.75×" },
|
||||
{ speed: 1.25, label: "1.25×" },
|
||||
{ speed: 1.5, label: "1.5×" },
|
||||
{ speed: 1.75, label: "1.75×" },
|
||||
{ speed: 2, label: "2×" },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
|
||||
|
||||
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
1: 1.25,
|
||||
2: 1.5,
|
||||
3: 1.8,
|
||||
4: 2.2,
|
||||
5: 3.5,
|
||||
6: 5.0,
|
||||
1: 1.25,
|
||||
2: 1.5,
|
||||
3: 1.8,
|
||||
4: 2.2,
|
||||
5: 3.5,
|
||||
6: 5.0,
|
||||
};
|
||||
|
||||
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
|
||||
|
||||
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
|
||||
return {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
return {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
if (Number.isNaN(value)) return (min + max) / 2;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
if (Number.isNaN(value)) return (min + max) / 2;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ZoomFocus } from "../types";
|
||||
|
||||
export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 };
|
||||
export const TRANSITION_WINDOW_MS = 320;
|
||||
export const SMOOTHING_FACTOR = 0.12;
|
||||
export const TRANSITION_WINDOW_MS = 1015.05;
|
||||
export const ZOOM_IN_TRANSITION_WINDOW_MS = TRANSITION_WINDOW_MS * 1.5;
|
||||
export const MIN_DELTA = 0.0001;
|
||||
export const VIEWPORT_SCALE = 0.8;
|
||||
export const SMOOTHING_FACTOR = 0.12;
|
||||
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
|
||||
export const ZOOM_SCALE_DEADZONE = 0.002;
|
||||
|
||||
@@ -1,54 +1,125 @@
|
||||
import { ZOOM_DEPTH_SCALES, clampFocusToDepth, type ZoomFocus, type ZoomDepth } from "../types";
|
||||
import { clampFocusToDepth, ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus } from "../types";
|
||||
|
||||
interface StageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function easeIntoBoundary(normalized: number) {
|
||||
const t = clamp(normalized, 0, 1);
|
||||
return -t * t * t + 2 * t * t;
|
||||
}
|
||||
|
||||
function softClampToRange(value: number, min: number, max: number, softness: number) {
|
||||
const clamped = clamp(value, min, max);
|
||||
|
||||
if (softness <= 0 || max <= min) {
|
||||
return clamped;
|
||||
}
|
||||
|
||||
if (clamped < min + softness) {
|
||||
const normalized = (clamped - min) / softness;
|
||||
return min + softness * easeIntoBoundary(normalized);
|
||||
}
|
||||
|
||||
if (clamped > max - softness) {
|
||||
const normalized = (max - clamped) / softness;
|
||||
return max - softness * easeIntoBoundary(normalized);
|
||||
}
|
||||
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function getFocusBounds(depth: ZoomDepth) {
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[depth];
|
||||
return getFocusBoundsForScale(zoomScale);
|
||||
}
|
||||
|
||||
function getFocusBoundsForScale(zoomScale: number) {
|
||||
const marginX = 1 / (2 * zoomScale);
|
||||
const marginY = 1 / (2 * zoomScale);
|
||||
|
||||
return {
|
||||
minX: marginX,
|
||||
maxX: 1 - marginX,
|
||||
minY: marginY,
|
||||
maxY: 1 - marginY,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampFocusToStage(
|
||||
focus: ZoomFocus,
|
||||
depth: ZoomDepth,
|
||||
stageSize: StageSize
|
||||
focus: ZoomFocus,
|
||||
depth: ZoomDepth,
|
||||
_stageSize: StageSize,
|
||||
): ZoomFocus {
|
||||
if (!stageSize.width || !stageSize.height) {
|
||||
return clampFocusToDepth(focus, depth);
|
||||
}
|
||||
const baseFocus = clampFocusToDepth(focus, depth);
|
||||
const bounds = getFocusBounds(depth);
|
||||
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[depth];
|
||||
|
||||
const windowWidth = stageSize.width / zoomScale;
|
||||
const windowHeight = stageSize.height / zoomScale;
|
||||
|
||||
const marginX = windowWidth / (2 * stageSize.width);
|
||||
const marginY = windowHeight / (2 * stageSize.height);
|
||||
return {
|
||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||
cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
|
||||
};
|
||||
}
|
||||
|
||||
const baseFocus = clampFocusToDepth(focus, depth);
|
||||
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale);
|
||||
|
||||
return {
|
||||
cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)),
|
||||
cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)),
|
||||
};
|
||||
return {
|
||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||
cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale);
|
||||
const horizontalRange = bounds.maxX - bounds.minX;
|
||||
const verticalRange = bounds.maxY - bounds.minY;
|
||||
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
|
||||
const verticalSoftness = Math.min(0.12, verticalRange * 0.35);
|
||||
|
||||
return {
|
||||
cx: softClampToRange(baseFocus.cx, bounds.minX, bounds.maxX, horizontalSoftness),
|
||||
cy: softClampToRange(baseFocus.cy, bounds.minY, bounds.maxY, verticalSoftness),
|
||||
};
|
||||
}
|
||||
|
||||
export function stageFocusToVideoSpace(
|
||||
focus: ZoomFocus,
|
||||
stageSize: StageSize,
|
||||
videoSize: { width: number; height: number },
|
||||
baseScale: number,
|
||||
baseOffset: { x: number; y: number }
|
||||
focus: ZoomFocus,
|
||||
stageSize: StageSize,
|
||||
videoSize: { width: number; height: number },
|
||||
baseScale: number,
|
||||
baseOffset: { x: number; y: number },
|
||||
): ZoomFocus {
|
||||
if (!stageSize.width || !stageSize.height || !videoSize.width || !videoSize.height || baseScale <= 0) {
|
||||
return focus;
|
||||
}
|
||||
if (
|
||||
!stageSize.width ||
|
||||
!stageSize.height ||
|
||||
!videoSize.width ||
|
||||
!videoSize.height ||
|
||||
baseScale <= 0
|
||||
) {
|
||||
return focus;
|
||||
}
|
||||
|
||||
const stageX = focus.cx * stageSize.width;
|
||||
const stageY = focus.cy * stageSize.height;
|
||||
const stageX = focus.cx * stageSize.width;
|
||||
const stageY = focus.cy * stageSize.height;
|
||||
|
||||
const videoNormX = (stageX - baseOffset.x) / (videoSize.width * baseScale);
|
||||
const videoNormY = (stageY - baseOffset.y) / (videoSize.height * baseScale);
|
||||
const videoNormX = (stageX - baseOffset.x) / (videoSize.width * baseScale);
|
||||
const videoNormY = (stageY - baseOffset.y) / (videoSize.height * baseScale);
|
||||
|
||||
return {
|
||||
cx: videoNormX,
|
||||
cy: videoNormY,
|
||||
};
|
||||
return {
|
||||
cx: videoNormX,
|
||||
cy: videoNormY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * from './constants';
|
||||
export * from './mathUtils';
|
||||
export * from './zoomRegionUtils';
|
||||
export * from './focusUtils';
|
||||
export * from './overlayUtils';
|
||||
export * from './layoutUtils';
|
||||
export * from './zoomTransform';
|
||||
export * from './videoEventHandlers';
|
||||
export * from "./constants";
|
||||
export * from "./focusUtils";
|
||||
export * from "./layoutUtils";
|
||||
export * from "./mathUtils";
|
||||
export * from "./overlayUtils";
|
||||
export * from "./videoEventHandlers";
|
||||
export * from "./zoomRegionUtils";
|
||||
export * from "./zoomTransform";
|
||||
|
||||
@@ -1,111 +1,121 @@
|
||||
import { Application, Sprite, Graphics } from 'pixi.js';
|
||||
import type { CropRegion } from '../types';
|
||||
import { Application, Graphics, Sprite } from "pixi.js";
|
||||
import type { CropRegion } from "../types";
|
||||
|
||||
interface LayoutParams {
|
||||
container: HTMLDivElement;
|
||||
app: Application;
|
||||
videoSprite: Sprite;
|
||||
maskGraphics: Graphics;
|
||||
videoElement: HTMLVideoElement;
|
||||
cropRegion?: CropRegion;
|
||||
lockedVideoDimensions?: { width: number; height: number } | null;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
container: HTMLDivElement;
|
||||
app: Application;
|
||||
videoSprite: Sprite;
|
||||
maskGraphics: Graphics;
|
||||
videoElement: HTMLVideoElement;
|
||||
cropRegion?: CropRegion;
|
||||
lockedVideoDimensions?: { width: number; height: number } | null;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
stageSize: { width: number; height: number };
|
||||
videoSize: { width: number; height: number };
|
||||
baseScale: number;
|
||||
baseOffset: { x: number; y: number };
|
||||
maskRect: { x: number; y: number; width: number; height: number };
|
||||
cropBounds: { startX: number; endX: number; startY: number; endY: number };
|
||||
stageSize: { width: number; height: number };
|
||||
videoSize: { width: number; height: number };
|
||||
baseScale: number;
|
||||
baseOffset: { x: number; y: number };
|
||||
maskRect: { x: number; y: number; width: number; height: number };
|
||||
cropBounds: { startX: number; endX: number; startY: number; endY: number };
|
||||
}
|
||||
|
||||
export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions, borderRadius = 0, padding = 0 } = params;
|
||||
const {
|
||||
container,
|
||||
app,
|
||||
videoSprite,
|
||||
maskGraphics,
|
||||
videoElement,
|
||||
cropRegion,
|
||||
lockedVideoDimensions,
|
||||
borderRadius = 0,
|
||||
padding = 0,
|
||||
} = params;
|
||||
|
||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||
const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight;
|
||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||
const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight;
|
||||
|
||||
if (!videoWidth || !videoHeight) {
|
||||
return null;
|
||||
}
|
||||
if (!videoWidth || !videoHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
if (!width || !height) {
|
||||
return null;
|
||||
}
|
||||
if (!width || !height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
app.renderer.resize(width, height);
|
||||
app.canvas.style.width = '100%';
|
||||
app.canvas.style.height = '100%';
|
||||
app.renderer.resize(width, height);
|
||||
app.canvas.style.width = "100%";
|
||||
app.canvas.style.height = "100%";
|
||||
|
||||
// Apply crop region
|
||||
const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 };
|
||||
|
||||
// Calculate the cropped dimensions
|
||||
const croppedVideoWidth = videoWidth * crop.width;
|
||||
const croppedVideoHeight = videoHeight * crop.height;
|
||||
// Apply crop region
|
||||
const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 };
|
||||
|
||||
const cropStartX = crop.x * videoWidth;
|
||||
const cropStartY = crop.y * videoHeight;
|
||||
const cropEndX = cropStartX + croppedVideoWidth;
|
||||
const cropEndY = cropStartY + croppedVideoHeight;
|
||||
|
||||
// Calculate scale to fit the cropped area in the viewport
|
||||
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
|
||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
||||
const maxDisplayWidth = width * paddingScale;
|
||||
const maxDisplayHeight = height * paddingScale;
|
||||
// Calculate the cropped dimensions
|
||||
const croppedVideoWidth = videoWidth * crop.width;
|
||||
const croppedVideoHeight = videoHeight * crop.height;
|
||||
|
||||
const scale = Math.min(
|
||||
maxDisplayWidth / croppedVideoWidth,
|
||||
maxDisplayHeight / croppedVideoHeight,
|
||||
1
|
||||
);
|
||||
const cropStartX = crop.x * videoWidth;
|
||||
const cropStartY = crop.y * videoHeight;
|
||||
const cropEndX = cropStartX + croppedVideoWidth;
|
||||
const cropEndY = cropStartY + croppedVideoHeight;
|
||||
|
||||
videoSprite.scale.set(scale);
|
||||
|
||||
// Calculate display size of the full video at this scale
|
||||
const fullVideoDisplayWidth = videoWidth * scale;
|
||||
const fullVideoDisplayHeight = videoHeight * scale;
|
||||
|
||||
// Calculate display size of just the cropped region
|
||||
const croppedDisplayWidth = croppedVideoWidth * scale;
|
||||
const croppedDisplayHeight = croppedVideoHeight * scale;
|
||||
// Calculate scale to fit the cropped area in the viewport
|
||||
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
|
||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
||||
const maxDisplayWidth = width * paddingScale;
|
||||
const maxDisplayHeight = height * paddingScale;
|
||||
|
||||
// Center the cropped region in the container
|
||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
||||
|
||||
// Position the full video sprite so that when we apply the mask,
|
||||
// the cropped region appears centered
|
||||
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
|
||||
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
|
||||
// We want that point to be at centerOffsetX, centerOffsetY
|
||||
const spriteX = centerOffsetX - (crop.x * fullVideoDisplayWidth);
|
||||
const spriteY = centerOffsetY - (crop.y * fullVideoDisplayHeight);
|
||||
|
||||
videoSprite.position.set(spriteX, spriteY);
|
||||
const scale = Math.min(
|
||||
maxDisplayWidth / croppedVideoWidth,
|
||||
maxDisplayHeight / croppedVideoHeight,
|
||||
1,
|
||||
);
|
||||
|
||||
// Create a mask that only shows the cropped region (centered in container)
|
||||
const maskX = centerOffsetX;
|
||||
const maskY = centerOffsetY;
|
||||
|
||||
// Apply border radius
|
||||
maskGraphics.clear();
|
||||
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
|
||||
maskGraphics.fill({ color: 0xffffff });
|
||||
videoSprite.scale.set(scale);
|
||||
|
||||
return {
|
||||
stageSize: { width, height },
|
||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
baseScale: scale,
|
||||
baseOffset: { x: spriteX, y: spriteY },
|
||||
maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
||||
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
|
||||
};
|
||||
// Calculate display size of the full video at this scale
|
||||
const fullVideoDisplayWidth = videoWidth * scale;
|
||||
const fullVideoDisplayHeight = videoHeight * scale;
|
||||
|
||||
// Calculate display size of just the cropped region
|
||||
const croppedDisplayWidth = croppedVideoWidth * scale;
|
||||
const croppedDisplayHeight = croppedVideoHeight * scale;
|
||||
|
||||
// Center the cropped region in the container
|
||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
||||
|
||||
// Position the full video sprite so that when we apply the mask,
|
||||
// the cropped region appears centered
|
||||
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
|
||||
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
|
||||
// We want that point to be at centerOffsetX, centerOffsetY
|
||||
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
|
||||
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
|
||||
|
||||
videoSprite.position.set(spriteX, spriteY);
|
||||
|
||||
// Create a mask that only shows the cropped region (centered in container)
|
||||
const maskX = centerOffsetX;
|
||||
const maskY = centerOffsetY;
|
||||
|
||||
// Apply border radius
|
||||
maskGraphics.clear();
|
||||
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
|
||||
maskGraphics.fill({ color: 0xffffff });
|
||||
|
||||
return {
|
||||
stageSize: { width, height },
|
||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
baseScale: scale,
|
||||
baseOffset: { x: spriteX, y: spriteY },
|
||||
maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
||||
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,86 @@
|
||||
export function clamp01(value: number) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function sampleCubicBezier(a1: number, a2: number, t: number) {
|
||||
const oneMinusT = 1 - t;
|
||||
return 3 * a1 * oneMinusT * oneMinusT * t + 3 * a2 * oneMinusT * t * t + t * t * t;
|
||||
}
|
||||
|
||||
function sampleCubicBezierDerivative(a1: number, a2: number, t: number) {
|
||||
const oneMinusT = 1 - t;
|
||||
return 3 * a1 * oneMinusT * oneMinusT + 6 * (a2 - a1) * oneMinusT * t + 3 * (1 - a2) * t * t;
|
||||
}
|
||||
|
||||
export function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: number) {
|
||||
const targetX = clamp01(t);
|
||||
let solvedT = targetX;
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const currentX = sampleCubicBezier(x1, x2, solvedT) - targetX;
|
||||
const currentDerivative = sampleCubicBezierDerivative(x1, x2, solvedT);
|
||||
|
||||
if (Math.abs(currentX) < 1e-6 || Math.abs(currentDerivative) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
|
||||
solvedT -= currentX / currentDerivative;
|
||||
}
|
||||
|
||||
let lower = 0;
|
||||
let upper = 1;
|
||||
solvedT = clamp01(solvedT);
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const currentX = sampleCubicBezier(x1, x2, solvedT);
|
||||
if (Math.abs(currentX - targetX) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentX < targetX) {
|
||||
lower = solvedT;
|
||||
} else {
|
||||
upper = solvedT;
|
||||
}
|
||||
|
||||
solvedT = (lower + upper) / 2;
|
||||
}
|
||||
|
||||
return sampleCubicBezier(y1, y2, solvedT);
|
||||
}
|
||||
|
||||
export function easeOutExpo(t: number) {
|
||||
const clamped = clamp01(t);
|
||||
if (clamped === 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 1 - Math.pow(2, -7 * clamped);
|
||||
}
|
||||
|
||||
export function easeOutScreenStudio(t: number) {
|
||||
return cubicBezier(0.16, 1, 0.3, 1, t);
|
||||
}
|
||||
|
||||
export function smoothStep(t: number) {
|
||||
const clamped = clamp01(t);
|
||||
return clamped * clamped * (3 - 2 * clamped);
|
||||
const clamped = clamp01(t);
|
||||
return clamped * clamped * (3 - 2 * clamped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gentle ease-in-out cubic — slow start, smooth middle, gentle landing.
|
||||
* Used for zoom-in transitions.
|
||||
*/
|
||||
export function easeInOutCubic(t: number) {
|
||||
const x = clamp01(t);
|
||||
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease-out cubic — starts at speed, then decelerates to a gentle stop.
|
||||
* Used for zoom-out transitions so strength eases smoothly to zero.
|
||||
*/
|
||||
export function easeOutCubic(t: number) {
|
||||
const x = clamp01(t);
|
||||
return 1 - Math.pow(1 - x, 3);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,67 @@
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus } from "../types";
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion } from "../types";
|
||||
import { clampFocusToStage } from "./focusUtils";
|
||||
|
||||
interface OverlayUpdateParams {
|
||||
overlayEl: HTMLDivElement;
|
||||
indicatorEl: HTMLDivElement;
|
||||
region: ZoomRegion | null;
|
||||
focusOverride?: ZoomFocus;
|
||||
videoSize: { width: number; height: number };
|
||||
baseScale: number;
|
||||
isPlaying: boolean;
|
||||
overlayEl: HTMLDivElement;
|
||||
indicatorEl: HTMLDivElement;
|
||||
region: ZoomRegion | null;
|
||||
focusOverride?: ZoomFocus;
|
||||
videoSize: { width: number; height: number };
|
||||
baseScale: number;
|
||||
isPlaying: boolean;
|
||||
}
|
||||
|
||||
export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
||||
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
|
||||
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
|
||||
|
||||
if (!region) {
|
||||
indicatorEl.style.display = 'none';
|
||||
overlayEl.style.pointerEvents = 'none';
|
||||
return;
|
||||
}
|
||||
if (!region) {
|
||||
indicatorEl.style.display = "none";
|
||||
overlayEl.style.pointerEvents = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const stageWidth = overlayEl.clientWidth;
|
||||
const stageHeight = overlayEl.clientHeight;
|
||||
|
||||
if (!stageWidth || !stageHeight) {
|
||||
indicatorEl.style.display = 'none';
|
||||
overlayEl.style.pointerEvents = 'none';
|
||||
return;
|
||||
}
|
||||
const stageWidth = overlayEl.clientWidth;
|
||||
const stageHeight = overlayEl.clientHeight;
|
||||
|
||||
if (!videoSize.width || !videoSize.height || baseScale <= 0) {
|
||||
indicatorEl.style.display = 'none';
|
||||
overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto';
|
||||
return;
|
||||
}
|
||||
if (!stageWidth || !stageHeight) {
|
||||
indicatorEl.style.display = "none";
|
||||
overlayEl.style.pointerEvents = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
|
||||
const focus = clampFocusToStage(
|
||||
focusOverride ?? region.focus,
|
||||
region.depth,
|
||||
{ width: stageWidth, height: stageHeight }
|
||||
);
|
||||
if (!videoSize.width || !videoSize.height || baseScale <= 0) {
|
||||
indicatorEl.style.display = "none";
|
||||
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
|
||||
return;
|
||||
}
|
||||
|
||||
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
|
||||
const indicatorWidth = stageWidth / zoomScale;
|
||||
const indicatorHeight = stageHeight / zoomScale;
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
|
||||
const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth, {
|
||||
width: stageWidth,
|
||||
height: stageHeight,
|
||||
});
|
||||
|
||||
const rawLeft = focus.cx * stageWidth - indicatorWidth / 2;
|
||||
const rawTop = focus.cy * stageHeight - indicatorHeight / 2;
|
||||
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
|
||||
const indicatorWidth = stageWidth / zoomScale;
|
||||
const indicatorHeight = stageHeight / zoomScale;
|
||||
|
||||
const adjustedLeft = indicatorWidth >= stageWidth
|
||||
? (stageWidth - indicatorWidth) / 2
|
||||
: Math.max(0, Math.min(stageWidth - indicatorWidth, rawLeft));
|
||||
const rawLeft = focus.cx * stageWidth - indicatorWidth / 2;
|
||||
const rawTop = focus.cy * stageHeight - indicatorHeight / 2;
|
||||
|
||||
const adjustedTop = indicatorHeight >= stageHeight
|
||||
? (stageHeight - indicatorHeight) / 2
|
||||
: Math.max(0, Math.min(stageHeight - indicatorHeight, rawTop));
|
||||
const adjustedLeft =
|
||||
indicatorWidth >= stageWidth
|
||||
? (stageWidth - indicatorWidth) / 2
|
||||
: Math.max(0, Math.min(stageWidth - indicatorWidth, rawLeft));
|
||||
|
||||
indicatorEl.style.display = 'block';
|
||||
indicatorEl.style.width = `${indicatorWidth}px`;
|
||||
indicatorEl.style.height = `${indicatorHeight}px`;
|
||||
indicatorEl.style.left = `${adjustedLeft}px`;
|
||||
indicatorEl.style.top = `${adjustedTop}px`;
|
||||
overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto';
|
||||
const adjustedTop =
|
||||
indicatorHeight >= stageHeight
|
||||
? (stageHeight - indicatorHeight) / 2
|
||||
: Math.max(0, Math.min(stageHeight - indicatorHeight, rawTop));
|
||||
|
||||
indicatorEl.style.display = "block";
|
||||
indicatorEl.style.width = `${indicatorWidth}px`;
|
||||
indicatorEl.style.height = `${indicatorHeight}px`;
|
||||
indicatorEl.style.left = `${adjustedLeft}px`;
|
||||
indicatorEl.style.top = `${adjustedTop}px`;
|
||||
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
|
||||
}
|
||||
|
||||
@@ -1,136 +1,152 @@
|
||||
import type React from 'react';
|
||||
import type { TrimRegion } from '../types';
|
||||
import type React from "react";
|
||||
import type { SpeedRegion, TrimRegion } from "../types";
|
||||
|
||||
interface VideoEventHandlersParams {
|
||||
video: HTMLVideoElement;
|
||||
isSeekingRef: React.MutableRefObject<boolean>;
|
||||
isPlayingRef: React.MutableRefObject<boolean>;
|
||||
allowPlaybackRef: React.MutableRefObject<boolean>;
|
||||
currentTimeRef: React.MutableRefObject<number>;
|
||||
timeUpdateAnimationRef: React.MutableRefObject<number | null>;
|
||||
onPlayStateChange: (playing: boolean) => void;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||
video: HTMLVideoElement;
|
||||
isSeekingRef: React.MutableRefObject<boolean>;
|
||||
isPlayingRef: React.MutableRefObject<boolean>;
|
||||
allowPlaybackRef: React.MutableRefObject<boolean>;
|
||||
currentTimeRef: React.MutableRefObject<number>;
|
||||
timeUpdateAnimationRef: React.MutableRefObject<number | null>;
|
||||
onPlayStateChange: (playing: boolean) => void;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||
}
|
||||
|
||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const {
|
||||
video,
|
||||
isSeekingRef,
|
||||
isPlayingRef,
|
||||
allowPlaybackRef,
|
||||
currentTimeRef,
|
||||
timeUpdateAnimationRef,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
} = params;
|
||||
const {
|
||||
video,
|
||||
isSeekingRef,
|
||||
isPlayingRef,
|
||||
allowPlaybackRef,
|
||||
currentTimeRef,
|
||||
timeUpdateAnimationRef,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
} = params;
|
||||
|
||||
const emitTime = (timeValue: number) => {
|
||||
currentTimeRef.current = timeValue * 1000;
|
||||
onTimeUpdate(timeValue);
|
||||
};
|
||||
const emitTime = (timeValue: number) => {
|
||||
currentTimeRef.current = timeValue * 1000;
|
||||
onTimeUpdate(timeValue);
|
||||
};
|
||||
|
||||
// Helper function to check if current time is within a trim region
|
||||
const findActiveTrimRegion = (currentTimeMs: number): TrimRegion | null => {
|
||||
const trimRegions = trimRegionsRef.current;
|
||||
return trimRegions.find(
|
||||
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs
|
||||
) || null;
|
||||
};
|
||||
// Helper function to check if current time is within a trim region
|
||||
const findActiveTrimRegion = (currentTimeMs: number): TrimRegion | null => {
|
||||
const trimRegions = trimRegionsRef.current;
|
||||
return (
|
||||
trimRegions.find(
|
||||
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs,
|
||||
) || null
|
||||
);
|
||||
};
|
||||
|
||||
function updateTime() {
|
||||
if (!video) return;
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
// If we're in a trim region during playback, skip to the end of it
|
||||
if (activeTrimRegion && !video.paused && !video.ended) {
|
||||
const skipToTime = activeTrimRegion.endMs / 1000;
|
||||
|
||||
// If the skip would take us past the video duration, pause instead
|
||||
if (skipToTime >= video.duration) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.currentTime = skipToTime;
|
||||
emitTime(skipToTime);
|
||||
}
|
||||
} else {
|
||||
emitTime(video.currentTime);
|
||||
}
|
||||
|
||||
if (!video.paused && !video.ended) {
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
}
|
||||
}
|
||||
// Helper function to find the active speed region at the current time
|
||||
const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => {
|
||||
return (
|
||||
speedRegionsRef.current.find(
|
||||
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs,
|
||||
) || null
|
||||
);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
if (isSeekingRef.current) {
|
||||
video.pause();
|
||||
return;
|
||||
}
|
||||
function updateTime() {
|
||||
if (!video) return;
|
||||
|
||||
if (!allowPlaybackRef.current) {
|
||||
video.pause();
|
||||
return;
|
||||
}
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
isPlayingRef.current = true;
|
||||
onPlayStateChange(true);
|
||||
if (timeUpdateAnimationRef.current) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
}
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
};
|
||||
// If we're in a trim region during playback, skip to the end of it
|
||||
if (activeTrimRegion && !video.paused && !video.ended) {
|
||||
const skipToTime = activeTrimRegion.endMs / 1000;
|
||||
|
||||
const handlePause = () => {
|
||||
isPlayingRef.current = false;
|
||||
onPlayStateChange(false);
|
||||
if (timeUpdateAnimationRef.current) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
timeUpdateAnimationRef.current = null;
|
||||
}
|
||||
emitTime(video.currentTime);
|
||||
};
|
||||
// If the skip would take us past the video duration, pause instead
|
||||
if (skipToTime >= video.duration) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.currentTime = skipToTime;
|
||||
emitTime(skipToTime);
|
||||
}
|
||||
} else {
|
||||
// Apply playback speed from active speed region
|
||||
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
|
||||
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||||
emitTime(video.currentTime);
|
||||
}
|
||||
|
||||
const handleSeeked = () => {
|
||||
isSeekingRef.current = false;
|
||||
if (!video.paused && !video.ended) {
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
}
|
||||
}
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
// If we seeked into a trim region while playing, skip to the end
|
||||
if (activeTrimRegion && isPlayingRef.current && !video.paused) {
|
||||
const skipToTime = activeTrimRegion.endMs / 1000;
|
||||
|
||||
if (skipToTime >= video.duration) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.currentTime = skipToTime;
|
||||
emitTime(skipToTime);
|
||||
}
|
||||
} else {
|
||||
if (!isPlayingRef.current && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
emitTime(video.currentTime);
|
||||
}
|
||||
};
|
||||
const handlePlay = () => {
|
||||
if (isSeekingRef.current) {
|
||||
video.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSeeking = () => {
|
||||
isSeekingRef.current = true;
|
||||
if (!allowPlaybackRef.current) {
|
||||
video.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlayingRef.current && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
emitTime(video.currentTime);
|
||||
};
|
||||
isPlayingRef.current = true;
|
||||
onPlayStateChange(true);
|
||||
if (timeUpdateAnimationRef.current) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
}
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
return {
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleSeeked,
|
||||
handleSeeking,
|
||||
};
|
||||
const handlePause = () => {
|
||||
isPlayingRef.current = false;
|
||||
onPlayStateChange(false);
|
||||
if (timeUpdateAnimationRef.current) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
timeUpdateAnimationRef.current = null;
|
||||
}
|
||||
emitTime(video.currentTime);
|
||||
};
|
||||
|
||||
const handleSeeked = () => {
|
||||
isSeekingRef.current = false;
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
// If we seeked into a trim region while playing, skip to the end
|
||||
if (activeTrimRegion && isPlayingRef.current && !video.paused) {
|
||||
const skipToTime = activeTrimRegion.endMs / 1000;
|
||||
|
||||
if (skipToTime >= video.duration) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.currentTime = skipToTime;
|
||||
emitTime(skipToTime);
|
||||
}
|
||||
} else {
|
||||
if (!isPlayingRef.current && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
emitTime(video.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeeking = () => {
|
||||
isSeekingRef.current = true;
|
||||
|
||||
if (!isPlayingRef.current && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
emitTime(video.currentTime);
|
||||
};
|
||||
|
||||
return {
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleSeeked,
|
||||
handleSeeking,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,31 +1,224 @@
|
||||
import type { ZoomRegion } from "../types";
|
||||
import { smoothStep } from "./mathUtils";
|
||||
import { TRANSITION_WINDOW_MS } from "./constants";
|
||||
import type { ZoomFocus, ZoomRegion } from "../types";
|
||||
import { ZOOM_DEPTH_SCALES } from "../types";
|
||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
||||
|
||||
const CHAINED_ZOOM_PAN_GAP_MS = 1500;
|
||||
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
|
||||
const ZOOM_IN_OVERLAP_MS = 500;
|
||||
|
||||
type DominantRegionOptions = {
|
||||
connectZooms?: boolean;
|
||||
};
|
||||
|
||||
type ConnectedRegionPair = {
|
||||
currentRegion: ZoomRegion;
|
||||
nextRegion: ZoomRegion;
|
||||
transitionStart: number;
|
||||
transitionEnd: number;
|
||||
};
|
||||
|
||||
type ConnectedPanTransition = {
|
||||
progress: number;
|
||||
startFocus: ZoomFocus;
|
||||
endFocus: ZoomFocus;
|
||||
startScale: number;
|
||||
endScale: number;
|
||||
};
|
||||
|
||||
function lerp(start: number, end: number, amount: number) {
|
||||
return start + (end - start) * amount;
|
||||
}
|
||||
|
||||
function easeConnectedPan(value: number) {
|
||||
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
|
||||
}
|
||||
|
||||
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
|
||||
const leadInStart = region.startMs - TRANSITION_WINDOW_MS;
|
||||
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
|
||||
const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
|
||||
const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
|
||||
|
||||
if (timeMs < leadInStart || timeMs > leadOutEnd) {
|
||||
return 0;
|
||||
}
|
||||
if (timeMs < leadInStart || timeMs > leadOutEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS);
|
||||
const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS);
|
||||
return Math.min(fadeIn, fadeOut);
|
||||
if (timeMs < zoomInEnd) {
|
||||
const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
return easeOutScreenStudio(progress);
|
||||
}
|
||||
|
||||
if (timeMs <= region.endMs) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS);
|
||||
return 1 - easeOutScreenStudio(progress);
|
||||
}
|
||||
|
||||
export function findDominantRegion(regions: ZoomRegion[], timeMs: number) {
|
||||
let bestRegion: ZoomRegion | null = null;
|
||||
let bestStrength = 0;
|
||||
|
||||
for (const region of regions) {
|
||||
const strength = computeRegionStrength(region, timeMs);
|
||||
if (strength > bestStrength) {
|
||||
bestStrength = strength;
|
||||
bestRegion = region;
|
||||
}
|
||||
}
|
||||
|
||||
return { region: bestRegion, strength: bestStrength };
|
||||
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
|
||||
return {
|
||||
cx: lerp(start.cx, end.cx, amount),
|
||||
cy: lerp(start.cy, end.cy, amount),
|
||||
};
|
||||
}
|
||||
|
||||
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
|
||||
return clampFocusToScale(region.focus, zoomScale);
|
||||
}
|
||||
|
||||
function getConnectedRegionPairs(regions: ZoomRegion[]) {
|
||||
const sortedRegions = [...regions].sort((a, b) => a.startMs - b.startMs);
|
||||
const pairs: ConnectedRegionPair[] = [];
|
||||
|
||||
for (let index = 0; index < sortedRegions.length - 1; index += 1) {
|
||||
const currentRegion = sortedRegions[index];
|
||||
const nextRegion = sortedRegions[index + 1];
|
||||
const gapMs = nextRegion.startMs - currentRegion.endMs;
|
||||
|
||||
if (gapMs > CHAINED_ZOOM_PAN_GAP_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pairs.push({
|
||||
currentRegion,
|
||||
nextRegion,
|
||||
transitionStart: currentRegion.endMs,
|
||||
transitionEnd: currentRegion.endMs + CONNECTED_ZOOM_PAN_DURATION_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function getActiveRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
) {
|
||||
const activeRegions = regions
|
||||
.map((region) => {
|
||||
const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id);
|
||||
if (outgoingPair && timeMs > outgoingPair.currentRegion.endMs) {
|
||||
return { region, strength: 0 };
|
||||
}
|
||||
|
||||
const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id);
|
||||
if (incomingPair && timeMs < incomingPair.transitionEnd) {
|
||||
return { region, strength: 0 };
|
||||
}
|
||||
|
||||
return { region, strength: computeRegionStrength(region, timeMs) };
|
||||
})
|
||||
.filter((entry) => entry.strength > 0)
|
||||
.sort((left, right) => {
|
||||
if (right.strength !== left.strength) {
|
||||
return right.strength - left.strength;
|
||||
}
|
||||
|
||||
return right.region.startMs - left.region.startMs;
|
||||
});
|
||||
|
||||
if (activeRegions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeRegion = activeRegions[0].region;
|
||||
const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth];
|
||||
|
||||
return {
|
||||
region: {
|
||||
...activeRegion,
|
||||
focus: getResolvedFocus(activeRegion, activeScale),
|
||||
},
|
||||
strength: activeRegions[0].strength,
|
||||
blendedScale: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
|
||||
for (const pair of connectedPairs) {
|
||||
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
||||
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
|
||||
return {
|
||||
region: {
|
||||
...pair.nextRegion,
|
||||
focus: getResolvedFocus(pair.nextRegion, nextScale),
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
|
||||
for (const pair of connectedPairs) {
|
||||
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
||||
|
||||
if (timeMs < transitionStart || timeMs > transitionEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transitionProgress = easeConnectedPan(
|
||||
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
|
||||
);
|
||||
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
||||
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||
const currentFocus = getResolvedFocus(currentRegion, currentScale);
|
||||
const nextFocus = getResolvedFocus(nextRegion, nextScale);
|
||||
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||
|
||||
return {
|
||||
region: {
|
||||
...nextRegion,
|
||||
focus: transitionFocus,
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: transitionScale,
|
||||
transition: {
|
||||
progress: transitionProgress,
|
||||
startFocus: currentFocus,
|
||||
endFocus: nextFocus,
|
||||
startScale: currentScale,
|
||||
endScale: nextScale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findDominantRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
options: DominantRegionOptions = {},
|
||||
): {
|
||||
region: ZoomRegion | null;
|
||||
strength: number;
|
||||
blendedScale: number | null;
|
||||
transition: ConnectedPanTransition | null;
|
||||
} {
|
||||
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||
|
||||
if (options.connectZooms) {
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
|
||||
if (connectedTransition) {
|
||||
return connectedTransition;
|
||||
}
|
||||
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
|
||||
if (connectedHold) {
|
||||
return { ...connectedHold, transition: null };
|
||||
}
|
||||
}
|
||||
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
|
||||
return activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: { region: null, strength: 0, blendedScale: null, transition: null };
|
||||
}
|
||||
|
||||
@@ -1,61 +1,249 @@
|
||||
import { Container, BlurFilter } from 'pixi.js';
|
||||
import { BlurFilter, Container } from "pixi.js";
|
||||
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||
|
||||
const PEAK_VELOCITY_PPS = 1400;
|
||||
const MAX_BLUR_PX = 14;
|
||||
const VELOCITY_THRESHOLD_PPS = 12;
|
||||
const MAX_AMOUNT_BOOST = 2.2;
|
||||
|
||||
function getMotionBlurAmountResponse(motionBlurAmount: number) {
|
||||
const clampedAmount = Math.min(1, Math.max(0, motionBlurAmount));
|
||||
// Keep the low end usable while giving the top of the slider substantially more headroom.
|
||||
return clampedAmount * (1 + (MAX_AMOUNT_BOOST - 1) * clampedAmount);
|
||||
}
|
||||
|
||||
export interface MotionBlurState {
|
||||
lastFrameTimeMs: number;
|
||||
prevCamX: number;
|
||||
prevCamY: number;
|
||||
prevCamScale: number;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export function createMotionBlurState(): MotionBlurState {
|
||||
return {
|
||||
lastFrameTimeMs: 0,
|
||||
prevCamX: 0,
|
||||
prevCamY: 0,
|
||||
prevCamScale: 1,
|
||||
initialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface TransformParams {
|
||||
cameraContainer: Container;
|
||||
blurFilter: BlurFilter | null;
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
motionIntensity: number;
|
||||
isPlaying: boolean;
|
||||
motionBlurEnabled?: boolean;
|
||||
cameraContainer: Container;
|
||||
blurFilter: BlurFilter | null;
|
||||
motionBlurFilter?: MotionBlurFilter | null;
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
zoomProgress?: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
motionIntensity: number;
|
||||
motionVector?: { x: number; y: number };
|
||||
isPlaying: boolean;
|
||||
motionBlurAmount?: number;
|
||||
transformOverride?: AppliedTransform;
|
||||
motionBlurState?: MotionBlurState;
|
||||
frameTimeMs?: number;
|
||||
}
|
||||
|
||||
interface AppliedTransform {
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface FocusFromTransformGeometry {
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ZoomTransformGeometry {
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
zoomProgress?: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
}
|
||||
|
||||
export function computeZoomTransform({
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
zoomProgress = 1,
|
||||
focusX,
|
||||
focusY,
|
||||
}: ZoomTransformGeometry): AppliedTransform {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0
|
||||
) {
|
||||
return { scale: 1, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const progress = Math.min(1, Math.max(0, zoomProgress));
|
||||
const focusStagePxX = baseMask.x + focusX * baseMask.width;
|
||||
const focusStagePxY = baseMask.y + focusY * baseMask.height;
|
||||
const stageCenterX = stageSize.width / 2;
|
||||
const stageCenterY = stageSize.height / 2;
|
||||
const scale = 1 + (zoomScale - 1) * progress;
|
||||
const finalX = stageCenterX - focusStagePxX * zoomScale;
|
||||
const finalY = stageCenterY - focusStagePxY * zoomScale;
|
||||
|
||||
return {
|
||||
scale,
|
||||
x: finalX * progress,
|
||||
y: finalY * progress,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeFocusFromTransform({
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
x,
|
||||
y,
|
||||
}: FocusFromTransformGeometry) {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0 ||
|
||||
zoomScale <= 0
|
||||
) {
|
||||
return { cx: 0.5, cy: 0.5 };
|
||||
}
|
||||
|
||||
const stageCenterX = stageSize.width / 2;
|
||||
const stageCenterY = stageSize.height / 2;
|
||||
const focusStagePxX = (stageCenterX - x) / zoomScale;
|
||||
const focusStagePxY = (stageCenterY - y) / zoomScale;
|
||||
|
||||
return {
|
||||
cx: (focusStagePxX - baseMask.x) / baseMask.width,
|
||||
cy: (focusStagePxY - baseMask.y) / baseMask.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyZoomTransform({
|
||||
cameraContainer,
|
||||
blurFilter,
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
focusX,
|
||||
focusY,
|
||||
motionIntensity,
|
||||
isPlaying,
|
||||
motionBlurEnabled = true,
|
||||
}: TransformParams) {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
cameraContainer,
|
||||
blurFilter,
|
||||
motionBlurFilter,
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
zoomProgress = 1,
|
||||
focusX,
|
||||
focusY,
|
||||
motionIntensity: _motionIntensity,
|
||||
motionVector: _motionVector,
|
||||
isPlaying,
|
||||
motionBlurAmount = 0,
|
||||
transformOverride,
|
||||
motionBlurState,
|
||||
frameTimeMs,
|
||||
}: TransformParams): AppliedTransform {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0
|
||||
) {
|
||||
return { scale: 1, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
// The focus point in stage coordinates (where the user clicked/selected)
|
||||
const focusStagePxX = focusX * stageSize.width;
|
||||
const focusStagePxY = focusY * stageSize.height;
|
||||
|
||||
// Stage center (where we want the focus to end up after zoom)
|
||||
const stageCenterX = stageSize.width / 2;
|
||||
const stageCenterY = stageSize.height / 2;
|
||||
const transform =
|
||||
transformOverride ??
|
||||
computeZoomTransform({
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
zoomProgress,
|
||||
focusX,
|
||||
focusY,
|
||||
});
|
||||
|
||||
// Apply zoom scale to camera container
|
||||
cameraContainer.scale.set(zoomScale);
|
||||
// Apply position & scale to camera container
|
||||
cameraContainer.scale.set(transform.scale);
|
||||
cameraContainer.position.set(transform.x, transform.y);
|
||||
|
||||
// Calculate camera position to keep focus point centered
|
||||
// After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale)
|
||||
// We want it at stage center, so offset = center - (focus * scale)
|
||||
const cameraX = stageCenterX - focusStagePxX * zoomScale;
|
||||
const cameraY = stageCenterY - focusStagePxY * zoomScale;
|
||||
if (motionBlurState && motionBlurFilter && motionBlurAmount > 0 && isPlaying) {
|
||||
const now = frameTimeMs ?? performance.now();
|
||||
|
||||
cameraContainer.position.set(cameraX, cameraY);
|
||||
if (!motionBlurState.initialized) {
|
||||
motionBlurState.prevCamX = transform.x;
|
||||
motionBlurState.prevCamY = transform.y;
|
||||
motionBlurState.prevCamScale = transform.scale;
|
||||
motionBlurState.lastFrameTimeMs = now;
|
||||
motionBlurState.initialized = true;
|
||||
motionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
motionBlurFilter.kernelSize = 5;
|
||||
motionBlurFilter.offset = 0;
|
||||
if (blurFilter) blurFilter.blur = 0;
|
||||
} else {
|
||||
const dtMs = Math.min(80, Math.max(1, now - motionBlurState.lastFrameTimeMs));
|
||||
const dtSeconds = dtMs / 1000;
|
||||
motionBlurState.lastFrameTimeMs = now;
|
||||
const amountResponse = getMotionBlurAmountResponse(motionBlurAmount);
|
||||
|
||||
if (blurFilter) {
|
||||
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
|
||||
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
|
||||
blurFilter.blur = motionBlur;
|
||||
}
|
||||
// Camera displacement this frame (stage-px)
|
||||
const dx = transform.x - motionBlurState.prevCamX;
|
||||
const dy = transform.y - motionBlurState.prevCamY;
|
||||
const dScale = transform.scale - motionBlurState.prevCamScale;
|
||||
|
||||
motionBlurState.prevCamX = transform.x;
|
||||
motionBlurState.prevCamY = transform.y;
|
||||
motionBlurState.prevCamScale = transform.scale;
|
||||
|
||||
// Velocity in px/s (translation + scale-change contribution)
|
||||
const velocityX = dx / dtSeconds;
|
||||
const velocityY = dy / dtSeconds;
|
||||
const scaleVelocity =
|
||||
Math.abs(dScale / dtSeconds) * Math.max(stageSize.width, stageSize.height) * 0.5;
|
||||
const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY) + scaleVelocity;
|
||||
|
||||
const normalised = Math.min(1, speed / PEAK_VELOCITY_PPS);
|
||||
const targetBlur =
|
||||
speed < VELOCITY_THRESHOLD_PPS ? 0 : normalised * normalised * MAX_BLUR_PX * amountResponse;
|
||||
|
||||
const dirMag = Math.sqrt(velocityX * velocityX + velocityY * velocityY) || 1;
|
||||
const velocityScale = targetBlur * 2.4;
|
||||
motionBlurFilter.velocity =
|
||||
targetBlur > 0
|
||||
? { x: (velocityX / dirMag) * velocityScale, y: (velocityY / dirMag) * velocityScale }
|
||||
: { x: 0, y: 0 };
|
||||
motionBlurFilter.kernelSize = targetBlur > 8 ? 15 : targetBlur > 4 ? 11 : 7;
|
||||
motionBlurFilter.offset = targetBlur > 0.5 ? -0.2 : 0;
|
||||
|
||||
if (blurFilter) {
|
||||
blurFilter.blur = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (motionBlurFilter) {
|
||||
motionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
motionBlurFilter.kernelSize = 5;
|
||||
motionBlurFilter.offset = 0;
|
||||
}
|
||||
if (blurFilter) {
|
||||
blurFilter.blur = 0;
|
||||
}
|
||||
if (motionBlurState) {
|
||||
motionBlurState.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scale: transform.scale,
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from "@/lib/shortcuts";
|
||||
import { isMac as getIsMac } from "@/utils/platformUtils";
|
||||
|
||||
interface ShortcutsContextValue {
|
||||
shortcuts: ShortcutsConfig;
|
||||
isMac: boolean;
|
||||
setShortcuts: (config: ShortcutsConfig) => void;
|
||||
persistShortcuts: (config?: ShortcutsConfig) => Promise<void>;
|
||||
isConfigOpen: boolean;
|
||||
openConfig: () => void;
|
||||
closeConfig: () => void;
|
||||
}
|
||||
|
||||
const ShortcutsContext = createContext<ShortcutsContextValue | null>(null);
|
||||
|
||||
export function useShortcuts(): ShortcutsContextValue {
|
||||
const ctx = useContext(ShortcutsContext);
|
||||
if (!ctx) throw new Error("useShortcuts must be used within <ShortcutsProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ShortcutsProvider({ children }: { children: ReactNode }) {
|
||||
const [shortcuts, setShortcuts] = useState<ShortcutsConfig>(DEFAULT_SHORTCUTS);
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getIsMac()
|
||||
.then(setIsMac)
|
||||
.catch(() => {
|
||||
// Keep default non-mac fallback if detection fails.
|
||||
});
|
||||
|
||||
window.electronAPI
|
||||
.getShortcuts?.()
|
||||
.then((saved) => {
|
||||
if (saved) {
|
||||
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep default shortcuts if persisted settings can't be loaded.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const persistShortcuts = useCallback(
|
||||
async (config?: ShortcutsConfig) => {
|
||||
await window.electronAPI.saveShortcuts?.(config ?? shortcuts);
|
||||
},
|
||||
[shortcuts],
|
||||
);
|
||||
|
||||
const openConfig = useCallback(() => setIsConfigOpen(true), []);
|
||||
const closeConfig = useCallback(() => setIsConfigOpen(false), []);
|
||||
|
||||
const value = useMemo<ShortcutsContextValue>(
|
||||
() => ({
|
||||
shortcuts,
|
||||
isMac,
|
||||
setShortcuts,
|
||||
persistShortcuts,
|
||||
isConfigOpen,
|
||||
openConfig,
|
||||
closeConfig,
|
||||
}),
|
||||
[shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig],
|
||||
);
|
||||
|
||||
return <ShortcutsContext.Provider value={value}>{children}</ShortcutsContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface AudioLevelMeterOptions {
|
||||
enabled: boolean;
|
||||
deviceId?: string;
|
||||
smoothingFactor?: number;
|
||||
}
|
||||
|
||||
export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
|
||||
const [level, setLevel] = useState(0);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
cleanup();
|
||||
setLevel(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const startMonitoring = async () => {
|
||||
try {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: options.deviceId ? { deviceId: { exact: options.deviceId } } : true,
|
||||
video: false,
|
||||
};
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
|
||||
if (!mounted) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
streamRef.current = stream;
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
if (audioContext.state === "suspended") {
|
||||
await audioContext.resume();
|
||||
}
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = options.smoothingFactor ?? 0.8;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
source.connect(analyser);
|
||||
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
|
||||
const updateLevel = () => {
|
||||
if (!mounted || !analyserRef.current) return;
|
||||
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
sum += dataArray[i] * dataArray[i];
|
||||
}
|
||||
const rms = Math.sqrt(sum / dataArray.length);
|
||||
const normalizedLevel = Math.min(100, (rms / 255) * 100 * 2);
|
||||
|
||||
setLevel(normalizedLevel);
|
||||
animationFrameRef.current = requestAnimationFrame(updateLevel);
|
||||
};
|
||||
|
||||
updateLevel();
|
||||
} catch (err) {
|
||||
console.error("Error starting audio level monitoring:", err);
|
||||
if (mounted) {
|
||||
setLevel(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
startMonitoring();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [options.enabled, options.deviceId, options.smoothingFactor, cleanup]);
|
||||
|
||||
return { level };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||
// selection change would feel surprising to the user).
|
||||
export interface EditorState {
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions: TrimRegion[];
|
||||
speedRegions: SpeedRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
cropRegion: CropRegion;
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
}
|
||||
|
||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
zoomRegions: [],
|
||||
trimRegions: [],
|
||||
speedRegions: [],
|
||||
annotationRegions: [],
|
||||
cropRegion: DEFAULT_CROP_REGION,
|
||||
wallpaper: "/wallpapers/wallpaper1.jpg",
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
borderRadius: 0,
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
};
|
||||
|
||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||
|
||||
interface History {
|
||||
past: EditorState[];
|
||||
present: EditorState;
|
||||
future: EditorState[];
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 80;
|
||||
|
||||
function resolve(present: EditorState, update: StateUpdate): EditorState {
|
||||
const partial = typeof update === "function" ? update(present) : update;
|
||||
return { ...present, ...partial };
|
||||
}
|
||||
|
||||
function withCheckpoint(history: History, newPresent: EditorState): History {
|
||||
return {
|
||||
past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present],
|
||||
present: newPresent,
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) {
|
||||
const [history, setHistory] = useState<History>({ past: [], present: initial, future: [] });
|
||||
|
||||
// Tracks whether a live-update series (e.g. slider drag) is in progress.
|
||||
// The first updateState call saves the pre-interaction state as a checkpoint.
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
const pushState = useCallback((update: StateUpdate) => {
|
||||
setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update)));
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const updateState = useCallback((update: StateUpdate) => {
|
||||
const isFirst = !dirtyRef.current;
|
||||
dirtyRef.current = true;
|
||||
setHistory((prev) => {
|
||||
const next = resolve(prev.present, update);
|
||||
return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const commitState = useCallback(() => {
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
if (!prev.past.length) return prev;
|
||||
const previous = prev.past[prev.past.length - 1];
|
||||
return {
|
||||
past: prev.past.slice(0, -1),
|
||||
present: previous,
|
||||
future: [prev.present, ...prev.future],
|
||||
};
|
||||
});
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
if (!prev.future.length) return prev;
|
||||
const [next, ...remainingFuture] = prev.future;
|
||||
return { past: [...prev.past, prev.present], present: next, future: remainingFuture };
|
||||
});
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state: history.present,
|
||||
pushState,
|
||||
updateState,
|
||||
commitState,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface MicrophoneDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export function useMicrophoneDevices(enabled: boolean = true) {
|
||||
const [devices, setDevices] = useState<MicrophoneDevice[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("default");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Request permission first to get actual device labels
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = allDevices
|
||||
.filter((device) => device.kind === "audioinput")
|
||||
.map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
|
||||
groupId: device.groupId,
|
||||
}));
|
||||
|
||||
// Stop the permission stream
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
|
||||
if (mounted) {
|
||||
setDevices(audioInputs);
|
||||
if (selectedDeviceId === "default" && audioInputs.length > 0) {
|
||||
setSelectedDeviceId(audioInputs[0].deviceId);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to enumerate audio devices";
|
||||
setError(errorMessage);
|
||||
setIsLoading(false);
|
||||
console.error("Error loading microphone devices:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadDevices();
|
||||
|
||||
const handleDeviceChange = () => {
|
||||
loadDevices();
|
||||
};
|
||||
|
||||
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
||||
};
|
||||
}, [enabled, selectedDeviceId]);
|
||||
|
||||
return {
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
setSelectedDeviceId,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,191 +1,611 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
const MIN_FRAME_RATE = 30;
|
||||
const TARGET_WIDTH = 3840;
|
||||
const TARGET_HEIGHT = 2160;
|
||||
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
|
||||
const QHD_WIDTH = 2560;
|
||||
const QHD_HEIGHT = 1440;
|
||||
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
|
||||
|
||||
const BITRATE_4K = 45_000_000;
|
||||
const BITRATE_QHD = 28_000_000;
|
||||
const BITRATE_BASE = 18_000_000;
|
||||
const HIGH_FRAME_RATE_THRESHOLD = 60;
|
||||
const HIGH_FRAME_RATE_BOOST = 1.7;
|
||||
|
||||
const DEFAULT_WIDTH = 1920;
|
||||
const DEFAULT_HEIGHT = 1080;
|
||||
|
||||
const CODEC_ALIGNMENT = 2;
|
||||
|
||||
const RECORDER_TIMESLICE_MS = 1000;
|
||||
const BITS_PER_MEGABIT = 1_000_000;
|
||||
const CHROME_MEDIA_SOURCE = "desktop";
|
||||
const RECORDING_FILE_PREFIX = "recording-";
|
||||
const VIDEO_FILE_EXTENSION = ".webm";
|
||||
const WEBCAM_FILE_SUFFIX = "-webcam";
|
||||
|
||||
const AUDIO_BITRATE_VOICE = 128_000;
|
||||
const AUDIO_BITRATE_SYSTEM = 192_000;
|
||||
|
||||
const MIC_GAIN_BOOST = 1.4;
|
||||
const WEBCAM_TARGET_WIDTH = 1280;
|
||||
const WEBCAM_TARGET_HEIGHT = 720;
|
||||
const WEBCAM_TARGET_FRAME_RATE = 30;
|
||||
|
||||
type UseScreenRecorderReturn = {
|
||||
recording: boolean;
|
||||
toggleRecording: () => void;
|
||||
recording: boolean;
|
||||
toggleRecording: () => void;
|
||||
restartRecording: () => void;
|
||||
microphoneEnabled: boolean;
|
||||
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||
microphoneDeviceId: string | undefined;
|
||||
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
||||
systemAudioEnabled: boolean;
|
||||
setSystemAudioEnabled: (enabled: boolean) => void;
|
||||
webcamEnabled: boolean;
|
||||
setWebcamEnabled: (enabled: boolean) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
||||
const stream = useRef<MediaStream | null>(null);
|
||||
const chunks = useRef<Blob[]>([]);
|
||||
const startTime = useRef<number>(0);
|
||||
type RecorderHandle = {
|
||||
recorder: MediaRecorder;
|
||||
recordedBlobPromise: Promise<Blob>;
|
||||
};
|
||||
|
||||
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
const TARGET_WIDTH = 3840;
|
||||
const TARGET_HEIGHT = 2160;
|
||||
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
|
||||
const selectMimeType = () => {
|
||||
const preferred = [
|
||||
"video/webm;codecs=av1",
|
||||
"video/webm;codecs=h264",
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm"
|
||||
];
|
||||
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
||||
const recorder = new MediaRecorder(stream, options);
|
||||
const chunks: Blob[] = [];
|
||||
const mimeType = options.mimeType || "video/webm";
|
||||
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
|
||||
recorder.ondataavailable = (event: BlobEvent) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onerror = () => {
|
||||
reject(new Error("Recording failed"));
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
resolve(new Blob(chunks, { type: mimeType }));
|
||||
};
|
||||
});
|
||||
|
||||
return preferred.find(type => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
|
||||
};
|
||||
|
||||
const computeBitrate = (width: number, height: number) => {
|
||||
const pixels = width * height;
|
||||
const highFrameRateBoost = TARGET_FRAME_RATE >= 60 ? 1.7 : 1;
|
||||
|
||||
if (pixels >= FOUR_K_PIXELS) {
|
||||
return Math.round(45_000_000 * highFrameRateBoost);
|
||||
}
|
||||
|
||||
if (pixels >= 2560 * 1440) {
|
||||
return Math.round(28_000_000 * highFrameRateBoost);
|
||||
}
|
||||
|
||||
return Math.round(18_000_000 * highFrameRateBoost);
|
||||
};
|
||||
|
||||
const stopRecording = useRef(() => {
|
||||
if (mediaRecorder.current?.state === "recording") {
|
||||
if (stream.current) {
|
||||
stream.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
mediaRecorder.current.stop();
|
||||
setRecording(false);
|
||||
|
||||
window.electronAPI?.setRecordingState(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
if (window.electronAPI?.onStopRecordingFromTray) {
|
||||
cleanup = window.electronAPI.onStopRecordingFromTray(() => {
|
||||
stopRecording.current();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (cleanup) cleanup();
|
||||
|
||||
if (mediaRecorder.current?.state === "recording") {
|
||||
mediaRecorder.current.stop();
|
||||
}
|
||||
if (stream.current) {
|
||||
stream.current.getTracks().forEach(track => track.stop());
|
||||
stream.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const selectedSource = await window.electronAPI.getSelectedSource();
|
||||
if (!selectedSource) {
|
||||
alert("Please select a source to record");
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaStream = await (navigator.mediaDevices as any).getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
maxWidth: TARGET_WIDTH,
|
||||
maxHeight: TARGET_HEIGHT,
|
||||
maxFrameRate: TARGET_FRAME_RATE,
|
||||
minFrameRate: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
stream.current = mediaStream;
|
||||
if (!stream.current) {
|
||||
throw new Error("Media stream is not available.");
|
||||
}
|
||||
const videoTrack = stream.current.getVideoTracks()[0];
|
||||
try {
|
||||
await videoTrack.applyConstraints({
|
||||
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
||||
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
|
||||
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Unable to lock 4K/60fps constraints, using best available track settings.", error);
|
||||
}
|
||||
|
||||
let { width = 1920, height = 1080, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings();
|
||||
|
||||
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
|
||||
width = Math.floor(width / 2) * 2;
|
||||
height = Math.floor(height / 2) * 2;
|
||||
|
||||
const videoBitsPerSecond = computeBitrate(width, height);
|
||||
const mimeType = selectMimeType();
|
||||
|
||||
console.log(
|
||||
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
|
||||
videoBitsPerSecond / 1_000_000
|
||||
)} Mbps`
|
||||
);
|
||||
|
||||
chunks.current = [];
|
||||
const recorder = new MediaRecorder(stream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
});
|
||||
mediaRecorder.current = recorder;
|
||||
recorder.ondataavailable = e => {
|
||||
if (e.data && e.data.size > 0) chunks.current.push(e.data);
|
||||
};
|
||||
recorder.onstop = async () => {
|
||||
stream.current = null;
|
||||
if (chunks.current.length === 0) return;
|
||||
const duration = Date.now() - startTime.current;
|
||||
const recordedChunks = chunks.current;
|
||||
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
|
||||
// Clear chunks early to free memory immediately after blob creation
|
||||
chunks.current = [];
|
||||
const timestamp = Date.now();
|
||||
const videoFileName = `recording-${timestamp}.webm`;
|
||||
|
||||
try {
|
||||
const videoBlob = await fixWebmDuration(buggyBlob, duration);
|
||||
const arrayBuffer = await videoBlob.arrayBuffer();
|
||||
const videoResult = await window.electronAPI.storeRecordedVideo(arrayBuffer, videoFileName);
|
||||
if (!videoResult.success) {
|
||||
console.error('Failed to store video:', videoResult.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoResult.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(videoResult.path);
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
} catch (error) {
|
||||
console.error('Error saving recording:', error);
|
||||
}
|
||||
};
|
||||
recorder.onerror = () => setRecording(false);
|
||||
recorder.start(1000);
|
||||
startTime.current = Date.now();
|
||||
setRecording(true);
|
||||
window.electronAPI?.setRecordingState(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
setRecording(false);
|
||||
if (stream.current) {
|
||||
stream.current.getTracks().forEach(track => track.stop());
|
||||
stream.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecording = () => {
|
||||
recording ? stopRecording.current() : startRecording();
|
||||
};
|
||||
|
||||
return { recording, toggleRecording };
|
||||
recorder.start(RECORDER_TIMESLICE_MS);
|
||||
return { recorder, recordedBlobPromise };
|
||||
}
|
||||
|
||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
||||
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
||||
const screenRecorder = useRef<RecorderHandle | null>(null);
|
||||
const webcamRecorder = useRef<RecorderHandle | null>(null);
|
||||
const stream = useRef<MediaStream | null>(null);
|
||||
const screenStream = useRef<MediaStream | null>(null);
|
||||
const microphoneStream = useRef<MediaStream | null>(null);
|
||||
const webcamStream = useRef<MediaStream | null>(null);
|
||||
const mixingContext = useRef<AudioContext | null>(null);
|
||||
const startTime = useRef<number>(0);
|
||||
const recordingId = useRef<number>(0);
|
||||
const finalizingRecordingId = useRef<number | null>(null);
|
||||
const allowAutoFinalize = useRef(false);
|
||||
const discardRecordingId = useRef<number | null>(null);
|
||||
const restarting = useRef(false);
|
||||
|
||||
const selectMimeType = () => {
|
||||
const preferred = [
|
||||
"video/webm;codecs=av1",
|
||||
"video/webm;codecs=h264",
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm",
|
||||
];
|
||||
|
||||
return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
|
||||
};
|
||||
|
||||
const computeBitrate = (width: number, height: number) => {
|
||||
const pixels = width * height;
|
||||
const highFrameRateBoost =
|
||||
TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
|
||||
|
||||
if (pixels >= FOUR_K_PIXELS) {
|
||||
return Math.round(BITRATE_4K * highFrameRateBoost);
|
||||
}
|
||||
|
||||
if (pixels >= QHD_PIXELS) {
|
||||
return Math.round(BITRATE_QHD * highFrameRateBoost);
|
||||
}
|
||||
|
||||
return Math.round(BITRATE_BASE * highFrameRateBoost);
|
||||
};
|
||||
|
||||
const teardownMedia = useCallback(() => {
|
||||
if (stream.current) {
|
||||
stream.current.getTracks().forEach((track) => track.stop());
|
||||
stream.current = null;
|
||||
}
|
||||
if (screenStream.current) {
|
||||
screenStream.current.getTracks().forEach((track) => track.stop());
|
||||
screenStream.current = null;
|
||||
}
|
||||
if (microphoneStream.current) {
|
||||
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
||||
microphoneStream.current = null;
|
||||
}
|
||||
if (webcamStream.current) {
|
||||
webcamStream.current.getTracks().forEach((track) => track.stop());
|
||||
webcamStream.current = null;
|
||||
}
|
||||
if (mixingContext.current) {
|
||||
mixingContext.current.close().catch(() => {
|
||||
// Ignore close errors during recorder teardown.
|
||||
});
|
||||
mixingContext.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setWebcamEnabled = useCallback(async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
setWebcamEnabledState(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
const accessResult = await window.electronAPI.requestCameraAccess();
|
||||
if (!accessResult.success) {
|
||||
toast.error("Failed to request camera access.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!accessResult.granted) {
|
||||
toast.error("Camera access is blocked. Enable it in system settings to use the webcam.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const probeStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: true,
|
||||
});
|
||||
probeStream.getTracks().forEach((track) => track.stop());
|
||||
setWebcamEnabledState(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("Failed to preflight webcam access:", error);
|
||||
toast.error("Camera access denied. Webcam overlay will stay disabled.");
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finalizeRecording = useCallback(
|
||||
(
|
||||
activeScreenRecorder: RecorderHandle,
|
||||
activeWebcamRecorder: RecorderHandle | null,
|
||||
duration: number,
|
||||
activeRecordingId: number,
|
||||
) => {
|
||||
if (finalizingRecordingId.current === activeRecordingId) {
|
||||
return;
|
||||
}
|
||||
finalizingRecordingId.current = activeRecordingId;
|
||||
|
||||
if (screenRecorder.current === activeScreenRecorder) {
|
||||
screenRecorder.current = null;
|
||||
}
|
||||
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
|
||||
webcamRecorder.current = null;
|
||||
}
|
||||
|
||||
teardownMedia();
|
||||
setRecording(false);
|
||||
window.electronAPI?.setRecordingState(false);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
||||
if (discardRecordingId.current === activeRecordingId) {
|
||||
return;
|
||||
}
|
||||
if (screenBlob.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
|
||||
let fixedWebcamBlob: Blob | null = null;
|
||||
if (activeWebcamRecorder) {
|
||||
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
||||
if (webcamBlob && webcamBlob.size > 0) {
|
||||
fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
||||
}
|
||||
}
|
||||
|
||||
const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
|
||||
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
||||
const result = await window.electronAPI.storeRecordedSession({
|
||||
screen: {
|
||||
videoData: await fixedScreenBlob.arrayBuffer(),
|
||||
fileName: screenFileName,
|
||||
},
|
||||
webcam: fixedWebcamBlob
|
||||
? {
|
||||
videoData: await fixedWebcamBlob.arrayBuffer(),
|
||||
fileName: webcamFileName,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: activeRecordingId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Failed to store recording session:", result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.session) {
|
||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||
} else if (result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
} catch (error) {
|
||||
console.error("Error saving recording:", error);
|
||||
} finally {
|
||||
if (finalizingRecordingId.current === activeRecordingId) {
|
||||
finalizingRecordingId.current = null;
|
||||
}
|
||||
if (discardRecordingId.current === activeRecordingId) {
|
||||
discardRecordingId.current = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
[teardownMedia],
|
||||
);
|
||||
|
||||
const stopRecording = useRef(() => {
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeWebcamRecorder = webcamRecorder.current;
|
||||
const duration = Date.now() - startTime.current;
|
||||
const activeRecordingId = recordingId.current;
|
||||
|
||||
finalizeRecording(
|
||||
activeScreenRecorder,
|
||||
activeWebcamRecorder ?? null,
|
||||
duration,
|
||||
activeRecordingId,
|
||||
);
|
||||
|
||||
if (activeScreenRecorder.recorder.state === "recording") {
|
||||
try {
|
||||
activeScreenRecorder.recorder.stop();
|
||||
} catch {
|
||||
// Recorder may already be stopping.
|
||||
}
|
||||
}
|
||||
if (activeWebcamRecorder) {
|
||||
if (activeWebcamRecorder.recorder.state === "recording") {
|
||||
try {
|
||||
activeWebcamRecorder.recorder.stop();
|
||||
} catch {
|
||||
// Recorder may already be stopping.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
if (window.electronAPI?.onStopRecordingFromTray) {
|
||||
cleanup = window.electronAPI.onStopRecordingFromTray(() => {
|
||||
stopRecording.current();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (cleanup) cleanup();
|
||||
allowAutoFinalize.current = false;
|
||||
restarting.current = false;
|
||||
discardRecordingId.current = null;
|
||||
|
||||
if (screenRecorder.current?.recorder.state === "recording") {
|
||||
try {
|
||||
screenRecorder.current.recorder.stop();
|
||||
} catch {
|
||||
// Ignore recorder teardown errors during cleanup.
|
||||
}
|
||||
}
|
||||
if (webcamRecorder.current?.recorder.state === "recording") {
|
||||
try {
|
||||
webcamRecorder.current.recorder.stop();
|
||||
} catch {
|
||||
// Ignore recorder teardown errors during cleanup.
|
||||
}
|
||||
}
|
||||
screenRecorder.current = null;
|
||||
webcamRecorder.current = null;
|
||||
teardownMedia();
|
||||
};
|
||||
}, [teardownMedia]);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const selectedSource = await window.electronAPI.getSelectedSource();
|
||||
if (!selectedSource) {
|
||||
alert("Please select a source to record");
|
||||
return;
|
||||
}
|
||||
|
||||
let screenMediaStream: MediaStream;
|
||||
|
||||
const videoConstraints = {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
maxWidth: TARGET_WIDTH,
|
||||
maxHeight: TARGET_HEIGHT,
|
||||
maxFrameRate: TARGET_FRAME_RATE,
|
||||
minFrameRate: MIN_FRAME_RATE,
|
||||
},
|
||||
};
|
||||
|
||||
if (systemAudioEnabled) {
|
||||
try {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
},
|
||||
},
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
} catch (audioErr) {
|
||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||
toast.error("System audio not available. Recording without system audio.");
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
} else {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
screenStream.current = screenMediaStream;
|
||||
|
||||
if (microphoneEnabled) {
|
||||
try {
|
||||
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
|
||||
audio: microphoneDeviceId
|
||||
? {
|
||||
deviceId: { exact: microphoneDeviceId },
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
}
|
||||
: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
video: false,
|
||||
});
|
||||
} catch (audioError) {
|
||||
console.warn("Failed to get microphone access:", audioError);
|
||||
toast.error("Microphone access denied. Recording will continue without audio.");
|
||||
setMicrophoneEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (webcamEnabled) {
|
||||
try {
|
||||
webcamStream.current = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
},
|
||||
});
|
||||
} catch (cameraError) {
|
||||
console.warn("Failed to get webcam access:", cameraError);
|
||||
if (webcamStream.current) {
|
||||
webcamStream.current.getTracks().forEach((track) => track.stop());
|
||||
webcamStream.current = null;
|
||||
}
|
||||
setWebcamEnabledState(false);
|
||||
toast.error("Camera access denied. Recording will continue without webcam.");
|
||||
}
|
||||
}
|
||||
|
||||
stream.current = new MediaStream();
|
||||
const videoTrack = screenMediaStream.getVideoTracks()[0];
|
||||
if (!videoTrack) {
|
||||
throw new Error("Video track is not available.");
|
||||
}
|
||||
stream.current.addTrack(videoTrack);
|
||||
|
||||
const systemAudioTrack = screenMediaStream.getAudioTracks()[0];
|
||||
const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
|
||||
|
||||
if (systemAudioTrack && micAudioTrack) {
|
||||
const ctx = new AudioContext();
|
||||
mixingContext.current = ctx;
|
||||
const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack]));
|
||||
const micSource = ctx.createMediaStreamSource(new MediaStream([micAudioTrack]));
|
||||
const micGain = ctx.createGain();
|
||||
micGain.gain.value = MIC_GAIN_BOOST;
|
||||
const destination = ctx.createMediaStreamDestination();
|
||||
systemSource.connect(destination);
|
||||
micSource.connect(micGain).connect(destination);
|
||||
stream.current.addTrack(destination.stream.getAudioTracks()[0]);
|
||||
} else if (systemAudioTrack) {
|
||||
stream.current.addTrack(systemAudioTrack);
|
||||
} else if (micAudioTrack) {
|
||||
stream.current.addTrack(micAudioTrack);
|
||||
}
|
||||
|
||||
try {
|
||||
await videoTrack.applyConstraints({
|
||||
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
||||
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
|
||||
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
|
||||
});
|
||||
} catch (constraintError) {
|
||||
console.warn(
|
||||
"Unable to lock 4K/60fps constraints, using best available track settings.",
|
||||
constraintError,
|
||||
);
|
||||
}
|
||||
|
||||
let {
|
||||
width = DEFAULT_WIDTH,
|
||||
height = DEFAULT_HEIGHT,
|
||||
frameRate = TARGET_FRAME_RATE,
|
||||
} = videoTrack.getSettings();
|
||||
|
||||
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
||||
height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
||||
|
||||
const videoBitsPerSecond = computeBitrate(width, height);
|
||||
const mimeType = selectMimeType();
|
||||
|
||||
console.log(
|
||||
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
|
||||
videoBitsPerSecond / BITS_PER_MEGABIT,
|
||||
)} Mbps`,
|
||||
);
|
||||
|
||||
const hasAudio = stream.current.getAudioTracks().length > 0;
|
||||
screenRecorder.current = createRecorderHandle(stream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
...(hasAudio
|
||||
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
||||
: {}),
|
||||
});
|
||||
screenRecorder.current.recorder.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
setRecording(false);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
if (webcamStream.current) {
|
||||
webcamRecorder.current = createRecorderHandle(webcamStream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE),
|
||||
});
|
||||
}
|
||||
|
||||
recordingId.current = Date.now();
|
||||
startTime.current = recordingId.current;
|
||||
allowAutoFinalize.current = true;
|
||||
setRecording(true);
|
||||
window.electronAPI?.setRecordingState(true);
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
const activeWebcamRecorder = webcamRecorder.current;
|
||||
const activeRecordingId = recordingId.current;
|
||||
if (activeScreenRecorder) {
|
||||
activeScreenRecorder.recorder.addEventListener(
|
||||
"stop",
|
||||
() => {
|
||||
if (!allowAutoFinalize.current) {
|
||||
return;
|
||||
}
|
||||
finalizeRecording(
|
||||
activeScreenRecorder,
|
||||
activeWebcamRecorder ?? null,
|
||||
Math.max(0, Date.now() - startTime.current),
|
||||
activeRecordingId,
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording:", error);
|
||||
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
||||
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
|
||||
toast.error("Recording permission denied. Please allow screen recording.");
|
||||
} else {
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
setRecording(false);
|
||||
screenRecorder.current = null;
|
||||
webcamRecorder.current = null;
|
||||
teardownMedia();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecording = () => {
|
||||
recording ? stopRecording.current() : startRecording();
|
||||
};
|
||||
|
||||
const restartRecording = async () => {
|
||||
if (restarting.current) return;
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
|
||||
|
||||
const activeWebcamRecorder = webcamRecorder.current;
|
||||
const activeRecordingId = recordingId.current;
|
||||
|
||||
restarting.current = true;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
|
||||
const stopPromises = [
|
||||
new Promise<void>((resolve) => {
|
||||
activeScreenRecorder.recorder.addEventListener("stop", () => resolve(), { once: true });
|
||||
}),
|
||||
];
|
||||
|
||||
if (activeWebcamRecorder?.recorder.state === "recording") {
|
||||
stopPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
stopRecording.current();
|
||||
await Promise.all(stopPromises);
|
||||
|
||||
try {
|
||||
await startRecording();
|
||||
} finally {
|
||||
restarting.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
recording,
|
||||
toggleRecording,
|
||||
restartRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
};
|
||||
}
|
||||
|
||||