Compare commits

..
204 Commits
Author SHA1 Message Date
Siddharth 991727d1c5 replace img 2026-03-07 22:13:33 -08:00
Siddharth 8e1e0e33e3 version update 2026-03-07 20:39:14 -08:00
Siddharth e02ef0d2c0 unsaved changes warning and loading project in hud 2026-03-07 19:44:00 -08:00
Siddharth fc7c1d28e5 update readme for new release 2026-03-07 19:32:53 -08:00
Siddharth 2553442a7d feat: add .editorconfig 2026-03-07 18:37:45 -08:00
Siddharth b3247a6a97 update ci check 2026-03-07 18:32:46 -08:00
Siddharth 124f2da992 fix unused 2026-03-07 18:17:29 -08:00
Siddharth 9343453365 add ci workflow 2026-03-07 18:12:44 -08:00
Siddharth 1802725581 pre commit hook and biome lint check 2026-03-07 18:03:32 -08:00
Siddharth 885d66c4a4 biome linting refactor 2026-03-07 17:59:41 -08:00
Siddharth 555b199e03 revamped HUD 2026-03-07 17:06:22 -08:00
Siddharth 371f79a35f system audio 2026-03-07 16:44:10 -08:00
Siddharth 64bc261c20 audio recording and settings 2026-03-07 15:56:11 -08:00
Siddharth 21e9f38be6 untrack 2026-03-07 13:51:08 -08:00
Sid a4fa260727 Merge pull request #182 from FabLrc/feature/fixing-timeline-on-long-video
Fixing timeline on long video
2026-03-07 13:35:20 -08:00
Siddharth 546bc7352c fix errors 2026-03-07 13:14:13 -08:00
Sid 9540a8c0a9 Merge pull request #163 from varaprasadreddy9676/feature/reveal-export-folder
feat: add reveal in folder option after export
2026-03-04 22:48:17 -08:00
SaiVaraprasad Medapati 60d3dfaef5 Merge branch 'main' into feature/reveal-export-folder 2026-03-04 19:33:39 +05:30
FabLrc cbfc242308 fix: adjust minimum item width and duration for better interaction on timeline 2026-03-03 12:35:20 +01:00
FabLrc f0779c96a3 fix: ensure minimum dimensions for timeline items and adjust duration constraints 2026-03-03 12:35:20 +01:00
Sid 9eb362012b Merge pull request #153 from yusufm/projectsave
Add project save/load files with File menu integration
2026-03-02 18:36:10 -08:00
Yusuf Mohsinally 843c130834 Merge main and address PR #153 review feedback 2026-03-01 21:13:19 -08:00
Sid f384338765 Merge pull request #179 from FabLrc/feature/speed-shortcut-configurable
fix: Add configurable shortcut for speed adjustment in TimelineEditor
2026-03-01 16:10:58 -08:00
FabLrc 0e082fff9c fix: Add configurable shortcut for speed adjustment in TimelineEditor 2026-03-01 23:08:05 +01:00
Sid 6ca24c3411 Merge pull request #176 from Brodypen/feature/speed-option
feat: Add speed option
2026-03-01 09:45:36 -08:00
Sid 31bc733415 Merge branch 'main' into feature/speed-option 2026-03-01 09:45:19 -08:00
Sid 451bb203b7 Merge pull request #172 from FabLrc/feature/shortcuts-configuration
Configurable keyboard shortcuts system
2026-03-01 09:36:25 -08:00
Fabien Laurence 57fdad0646 Merge branch 'main' into feature/shortcuts-configuration 2026-03-01 12:31:56 +01:00
Sid 71bb09c82e Merge pull request #177 from Brodypen/worktree-refactor/magic-number
refactor: replace magic numbers with named constants in useScreenRecorder
2026-02-28 13:03:08 -08:00
Siddharth 4ab8f3d1f1 export zoom focus clamping 2026-02-28 12:36:50 -08:00
Sid 4bac15cb44 Merge pull request #154 from yusufm/feat/cursor-telemetry-zoom-suggestions
feat: cursor telemetry-driven zoom suggestions
2026-02-28 12:13:44 -08:00
FabLrc d76f38fb35 feat: enhance shortcuts configuration with conflict detection and fixed shortcuts 2026-02-28 11:11:12 +01:00
Yusuf Mohsinally 236ca4da29 address PR #153 review feedback 2026-02-28 00:28:01 -08:00
Brodypen cf8d211eb2 feat: add the speed to exporter lol 2026-02-28 02:16:03 -06:00
Yusuf Mohsinally 4ecd18086c refactor: move zoom suggestion logic into timeline util 2026-02-28 00:06:29 -08:00
Yusuf Mohsinally a2b9eea90a feat: add cursor telemetry-driven zoom suggestions 2026-02-28 00:06:29 -08:00
Siddharth 4b3afcf535 annotation bounding and canvas wrapping 2026-02-27 23:44:02 -08:00
Brodypen 185969a9d1 build: package-lock stuff 2026-02-28 01:27:01 -06:00
Sid 5f20820735 Merge pull request #173 from FabLrc/feature/enhancing-export
fix: improve encoder queue management and adjust latency mode for beter troughput
2026-02-27 23:26:05 -08:00
Brodypen 397a943426 feat: speed thing 2026-02-28 01:20:04 -06:00
Brodypen 83d3e7b6b8 refactor: replace magic numbers with named constants in useScreenRecorder 2026-02-28 01:08:19 -06:00
Siddharth 5573c9f427 rm testing files 2026-02-27 21:04:31 -08:00
FabLrc 92d2a41296 fix: improve encoder queue management and adjust latency mode for better throughput 2026-02-27 00:24:27 +01:00
FabLrc 9bc2c78b4d feat: implement keyboard shortcuts management and configuration 2026-02-26 15:41:32 +01:00
Sid 87735c2716 Merge pull request #118 from IdrisGit/feat-add-biome-formatter-linter
feat: remove eslint and add biome for formatter and linter (RFC)
2026-02-23 14:42:51 -08:00
Idris Gadi 91c9de2561 feat: update package 2026-02-22 09:49:04 +05:30
Idris Gadi 9df9264d25 Merge branch 'main' into feat-add-biome-formatter-linter 2026-02-22 09:34:51 +05:30
saivaraprasadreddy medapati c6d33aa82a fix: await openPath fallback and other review fixes
- Fix IPC handler to properly await shell.openPath() promise
- Export dialog now shows file name below the button for better UX
- Toast message now generic (works for both video and GIF exports)
- Fixed formatting in electron type definitions
2026-02-21 02:06:20 +05:30
saivaraprasadreddy medapati 85f2388041 feat: add reveal in folder option after export
- Added electron IPC handler 'reveal-in-folder' to show exported file in finder
- Created toast notification with clickable action to reveal exported video
- Added Show in Folder button in export success dialog
- Implemented proper state management for exported file path
- Fixed timing issue where exportedFilePath was reset too early
2026-02-21 01:53:27 +05:30
Sid 44cf97c7a1 Merge pull request #146 from KoopaCode/main
Refined Launch Styling
2026-02-19 19:06:13 -08:00
Yusuf Mohsinally bd50b193a1 Add Save Project As menu action and force prompt behavior 2026-02-18 11:08:01 -08:00
Yusuf Mohsinally 491db0ab2e Add project file save/load workflow, menu actions, and persistence tests 2026-02-18 11:01:14 -08:00
Sid 518fe4ca15 Merge pull request #147 from NureddinSoltan/fix/linux-sandbox-docs
docs: add troubleshooting for Linux sandbox error
2026-02-17 07:44:52 -08:00
NureddinSoltan 647705fb58 docs: add troubleshooting for Linux sandbox error 2026-02-17 01:37:55 +03:00
Andrew P. Harper 59d786bfda Refined launch style
Added .hudBar style & tweaked background gradient, reduce blur/saturation, Added scrollbar style.
2026-02-16 00:11:04 -05:00
Siddharth 19476da5cc upgrade electron-builder version 2026-02-13 22:07:27 -08:00
Siddharth cdff1a9b5d fix path 2026-02-13 21:06:49 -08:00
Siddharth fac4af40c7 demuxer and CFR conversion 2026-02-13 20:46:12 -08:00
Siddharth d9177b4a44 more timeline ux qol improvements: 2026-02-12 22:53:19 -08:00
Siddharth 4d7e2a2d85 UX improved for timeline 2026-02-12 22:33:41 -08:00
Siddharth 8e94dcbc2c keyframe snap and move 2026-02-06 22:14:39 -08:00
Siddharth 05f4e74de6 google fonts 2026-02-06 21:58:07 -08:00
Siddharth a89198ccdc anti aliasing on 2026-02-06 21:28:42 -08:00
Sid 170dd2efd2 Merge pull request #120 from IdrisGit/feat-add-support-for-16-10-aspect-ratio
feat: add support for 16:10 aspect ratio
2026-02-06 20:24:21 -08:00
Sid d4704d554d Merge pull request #134 from JustinBenito/patch-1
Update README with Full Disk Access instructions
2026-02-06 20:23:00 -08:00
Justin Benito B 485d2fd098 Update README with Full Disk Access instructions
Added note about granting Full Disk Access for terminal.
2026-02-04 17:48:45 +05:30
Sid d5dbcb3a7a Merge pull request #119 from IdrisGit/docs-move-issue-templates-from-markdown-to-yaml-forms
docs: move issue templates to YAML forms
2026-01-31 20:55:21 -08:00
Sid e1760020ac Merge pull request #121 from IdrisGit/docs-readme-cleanup
docs: add the beta warning at top and move assests to public
2026-01-31 20:53:32 -08:00
Siddharth c8a4becaf0 version fix 2026-01-31 20:15:52 -08:00
Siddharth e6db74f183 stale closure bug 2026-01-31 20:05:19 -08:00
Idris Gadi 271a3ee443 chore: remove unused svgs 2026-01-27 20:53:36 +05:30
Idris Gadi cef987cd33 docs: add the beta warning at top and move assests to public
moving the assests to public prevents root from getting populated with
alot of files
2026-01-27 20:53:36 +05:30
Idris Gadi 95b4df0ae4 fix: types 2026-01-27 16:23:45 +05:30
Idris Gadi 0d27f4fc36 feat: add support for 16:10 aspect ratio 2026-01-27 15:59:01 +05:30
Idris Gadi 23aced6007 docs: move issue templates to YAML forms
Github has official support for forms
(https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms)

forms are much easire and intuitive for people to fill out compared to
markdown, this makes creating new issues easy and more structured.

should also help with preventing random issues.

I am intentionally not adding a blank template for now, if required it
can be added later.
2026-01-27 14:43:29 +05:30
Idris Gadi db9cf960f8 feat: remove eslint and add biome for formatter and linter 2026-01-27 12:53:33 +05:30
Sid afbd0740a3 Merge pull request #117 from IdrisGit/fix-turn-off-motion-blur-by-default
fix: set motion blur to disabled by default
2026-01-26 22:17:55 -08:00
Idris Gadi f30e2d654e fix: remove extra semi colon 2026-01-27 11:46:46 +05:30
Idris Gadi f2c6d8ff0f fix: set motion blur to disabled by default 2026-01-27 11:10:12 +05:30
Siddharth 9821e926d9 fix version 2026-01-24 22:45:07 -08:00
Siddharth a2ca0799d4 accordion & settings cleanup 2026-01-20 21:10:22 -08:00
Siddharth 6d2e1edb5b fix build errors 2026-01-20 20:05:14 -08:00
Sid 08f58b3539 Merge pull request #101 from Al-Farhan/bug/source-selector-tabs-content-height
fix(ui): increase height for source selector tabs content
2026-01-16 19:20:45 -08:00
Farhan Shaikh 1586dbe65e fix(ui): increase height for source selector tabs content 2026-01-13 16:42:19 +05:30
Sid 3a63617c55 Merge pull request #84 from solnikhil/export
feat(export): add GIF exporting, Frame Rate & Output Size selection, and loop animation
2026-01-11 10:27:18 -08:00
Nikhil Solanki e8d2c19b7d Merge branch 'main' into export 2026-01-11 21:52:29 +05:30
Nikhil Solanki 23ede0fcfa Update GIF export options to remove 10 FPS and small size
Removed 10 FPS from valid GIF frame rates and the 'small' size preset from GIF export options. Updated UI grid layouts and tests to reflect these changes for consistency.
2026-01-11 21:50:42 +05:30
Sid 78cfb64f98 Merge pull request #97 from twinkalp10/bug/fix-bg-image-selection-height
fix(ui): set minimum height for image selection tab in settings panel
2026-01-10 12:05:56 -08:00
Twinkal P 3124342309 fix(ui): set minimum height for image selection tab in settings panel 2026-01-10 17:33:11 +00:00
Sid a9bd2ac820 Merge pull request #90 from gerrywastaken/claude/openscreen-linux-boot-performance-8Xq9v
fix(linux): Greatly AppImage boot time from ~20s to ~2s
2026-01-01 12:05:13 -08:00
Claude e190915c48 fix(linux): reduce AppImage boot time from ~50s to near-instant
Change compression from "maximum" to "normal" for electron-builder.

The "maximum" compression setting causes gzip/xz compression in the
squashfs filesystem, which has extremely poor random access performance
(~35 MB/s). This results in 50+ second boot times on Linux AppImage
releases due to FUSE overhead during Electron's many small file reads
at startup.

With "normal" compression, the AppImage uses faster decompression
algorithms, dramatically improving startup time while only marginally
increasing package size.

Refs: electron-userland/electron-builder#6317
Refs: electron-userland/electron-builder#7483
2026-01-01 01:52:44 +00:00
Sid 171a02aef4 Merge pull request #72 from ateendra24/fix-issue-34
feat: add tutorial help component for video trimming guidance
2025-12-28 19:03:35 -08:00
Nikhil Solanki 085ebad38f Add custom hidden scrollbar styles and clean up code
Introduced CSS classes to hide scrollbars while maintaining scrollability across browsers. Also removed unnecessary blank lines in frameRenderer.ts for code cleanliness.
2025-12-25 16:12:34 +05:30
Nikhil Solanki f3e12629c2 Adjust layout and sizing of SettingsPanel tabs
Updated the Tabs and TabsList components in SettingsPanel to use fixed min and max heights and improved flex properties for better layout consistency and scrolling behavior.
2025-12-25 15:09:26 +05:30
Nikhil Solanki f00d381f94 fixed swloppy gitignore 2025-12-25 14:48:24 +05:30
Nikhil Solanki fb92b0b6d9 / 2025-12-25 02:05:38 +05:30
Nikhil Solanki 8ca2b8362a Update .gitignore 2025-12-25 02:02:08 +05:30
Nikhil Solanki 134f392553 Update .gitignore 2025-12-25 02:01:20 +05:30
Nikhil Solanki c7e81c6b7f Update .gitignore 2025-12-25 02:00:27 +05:30
Nikhil Solanki f58b8b2897 Mega gitignore 2025-12-25 02:00:06 +05:30
Nikhil Solanki 6e6ecba172 Add GIF export feature to video editor
Implements GIF export alongside MP4, including new export types, a GIF exporter module, UI components for format selection and GIF options, and integration into the export dialog and video editor. Adds property-based and unit tests for GIF export correctness, updates dependencies to include gif.js and related types, and refines Electron save dialog to support GIF files.
2025-12-25 01:50:02 +05:30
AP Solanki 175bb36eda feat: add tutorial help component for video trimming guidance 2025-12-18 10:38:07 +05:30
Sid 2ca99136ba Update README.md 2025-12-17 00:41:24 -07:00
Siddharth b7485865f3 fix:Border radius appears smaller in export compared to preview 2025-12-16 13:52:53 -07:00
Sid 7db2fa4e01 Merge pull request #65 from LauZzL/feature/system-tray-icon
feat(electron): implement dynamic tray icon and menu updates
2025-12-16 12:28:25 -08:00
LauZzL 81b59cad7c feat(electron): implement dynamic tray icon and menu updates
- Show "Stop Recording" menu & recording icon when recording
- Show "Open/Quit" menu & default icon when not recording
2025-12-16 21:28:18 +08:00
Sid 7e0ce53df0 Update package.json 2025-12-14 10:47:47 -07:00
Sid d57140b031 Merge pull request #59 from kamikazebr/feat/linux-support
feat: add Linux support
2025-12-14 09:33:11 -08:00
Felipe Novaes F Rocha 5dd85abaee chore: remove pnpm lock files from tracking 2025-12-14 14:29:23 -03:00
Felipe Novaes F Rocha 78fbd30b15 ci: add Linux build to workflow 2025-12-13 20:25:15 -03:00
Felipe Novaes F Rocha ebb1d29375 feat: add Linux support 2025-12-13 20:07:41 -03:00
Siddharth 250fc5d221 disable user-select 2025-12-10 21:30:25 -07:00
Sid 58b5ea0f9b Merge pull request #50 from suenyiyang/feat/optimize-clamped-content
feat: add content-clamp component to show full text when truncated
2025-12-09 13:00:38 -08:00
Yiyang Suen 4bc8a1e970 feat: remove content clamp popover content box shadow 2025-12-09 09:31:35 +08:00
Yiyang Suen 16752a7ae8 feat: add content-clamp component to show clamped text 2025-12-09 09:22:30 +08:00
Sid 5f4d20b26d Merge pull request #46 from LauZzL/fix/windows-close-button-not-work
fix(electron): remove platform check for hud overlay close event
2025-12-07 10:31:44 -08:00
LauZzL 8cbdcf2d7a fix(electron): remove platform check for hud overlay close event
This check causes the close button to stop working on Windows.
2025-12-07 17:51:19 +08:00
Siddharth f1f507e6e9 replace 2025-12-06 11:44:57 -07:00
Sid a7fb7670a7 - 2025-12-06 11:29:42 -07:00
Sid eccccf583b - 2025-12-06 11:23:34 -07:00
Siddharth d6d1a3eca6 build fix 2025-12-05 23:31:36 -07:00
Siddharth c5aa622898 hex based inputs for brand consitency 2025-12-05 23:22:30 -07:00
Siddharth 1345c8109c rename export res 2025-12-05 23:04:58 -07:00
Siddharth d91ed78fc2 delete trim ux improvement 2025-12-05 22:32:26 -07:00
Siddharth 5d7b817586 fix default wallpaper missing from export in build 2025-12-05 22:22:17 -07:00
Sid fecb9a9b22 Merge pull request #28 from ilGianfri/main
Add platform-aware keyboard shortcut formatting
2025-12-04 16:46:28 -08:00
Sid cbdef41667 Merge branch 'main' into main 2025-12-04 16:46:16 -08:00
Alessandro Spisso f34bd19183 feat: implement platform-aware keyboard shortcuts and add IPC handler for platform detection 2025-12-04 23:53:25 +01:00
Siddharth 7a7db0b277 revert exporter 2025-12-04 10:22:20 -07:00
Siddharth 3a4ec9c470 update assets 2025-12-02 23:25:40 -07:00
Siddharth d2a62b137d cleanup settings and readme 2025-12-02 21:49:38 -07:00
Siddharth c9e9d1d1bd missing shortcut 2025-12-02 18:50:53 -07:00
Siddharth cce88b3dab build errors and version update 2025-12-02 18:31:31 -07:00
Siddharth 4018741648 settings update 2025-12-02 18:11:00 -07:00
Siddharth ed3cdab64e export quality options 2025-12-02 17:41:30 -07:00
Siddharth 4ffa9c6ecb reduce seek bottleneck 2025-12-02 16:32:35 -07:00
Siddharth 899e55d257 update usescreenrecorder 2025-12-01 22:16:38 -07:00
Siddharth 977be1e3b1 draggable playhead and pause/play shortcut 2025-12-01 15:36:03 -07:00
Siddharth 262745a97f final annotation preview and export 2025-12-01 11:20:05 -07:00
Siddharth 6ac712eaac final annotation settings 2025-11-30 21:14:55 -07:00
Siddharth 79e40cef68 improved annotation experience 2025-11-30 19:19:08 -07:00
Siddharth c847953a52 allow multiple annotation conflicts, and cycle using Tab 2025-11-30 18:39:56 -07:00
Alessandro Spisso 391938049b Add platform-aware keyboard shortcut formatting
Introduces a new utility (platformUtils.ts) to format keyboard shortcuts based on the user's platform (macOS or others). Updates KeyboardShortcutsHelp and TimelineEditor to use the new formatShortcut function for displaying shortcuts, ensuring correct symbols are shown for modifier keys.
2025-12-01 00:19:34 +01:00
Siddharth 71ba4e4cea rm dead code 2025-11-30 15:35:03 -07:00
Siddharth ec3b9b46a1 annotations in preview 2025-11-30 14:47:22 -07:00
Sid 2ad5899417 Merge pull request #23 from suenyiyang/feat/disable-source-selection-when-recording
feat: disable source selection and project selection when recording
2025-11-29 19:34:41 -08:00
Yiyang Suen 1f08d3ca26 feat: disable source selection and project selection when recording 2025-11-30 10:19:12 +08:00
Sid bae17c0d1b Merge pull request #20 from siddharthvaddem/aspect-ratio
Aspect ratio
2025-11-29 10:42:48 -08:00
Siddharth 0c89e3e01a export aspect ratio 2025-11-29 11:38:09 -07:00
Siddharth d2ee511466 preview aspect ratio 2025-11-28 23:54:58 -07:00
Siddharth 4c725dfceb settings cleanup 2025-11-28 21:55:42 -07:00
Siddharth 71e2b51f5b padding video control 2025-11-28 21:46:05 -07:00
Siddharth c9321240d8 enable custom border radius 2025-11-28 19:15:56 -07:00
Siddharth 159f770da8 missing item depth level 2025-11-28 18:48:11 -07:00
Siddharth 443e4b0581 motion blur export stale closure 2025-11-28 18:04:38 -07:00
Siddharth 65bc21f153 fix the bug i just introduced lol 2025-11-28 17:41:30 -07:00
Siddharth 59807662d8 aspect ratio bug 2025-11-28 17:14:55 -07:00
Siddharth 8f31bde518 motion-blue switch 2025-11-28 16:08:11 -07:00
Sid 6fb9a24834 Merge pull request #14 from siddharthvaddem/feature/trim
Feature/trim
2025-11-27 22:31:33 -08:00
Siddharth 6a8b99c7bd spacing 2025-11-27 22:33:34 -07:00
Siddharth 1241de6e1a trim integration export 2025-11-27 22:24:17 -07:00
Siddharth 3998af5398 skip trimmed area seeking 2025-11-27 21:44:07 -07:00
Siddharth 2b5b15f3e8 basic trim setup 2025-11-27 16:35:21 -07:00
Siddharth e549850b75 hud overlay UX improvements 2025-11-27 15:12:38 -07:00
Siddharth b6d6fe6a70 draggable UI resizer 2025-11-27 14:17:10 -07:00
Siddharth e6cb86fafc prevent branch target runs 2025-11-26 11:46:52 -07:00
Sid 1dbbf8721c Merge pull request #11 from FlyingThaCat/main
add untracked uuid packages
2025-11-26 10:43:30 -08:00
john c63f5eddeb make the hud in right bottom corner 2025-11-26 23:12:04 +07:00
john 6475814541 add untracked uuid packages 2025-11-26 22:46:47 +07:00
Siddharth d85e6e1254 wallpaper count 2025-11-25 22:03:37 -07:00
Siddharth 6baeebec96 more zoom options, info popup 2025-11-25 21:43:30 -07:00
Siddharth ddf30ed60e record/ select your own video 2025-11-25 21:18:57 -07:00
Siddharth 98d6acaa6a keyframes 2025-11-25 18:45:37 -07:00
Siddharth 060a7bab92 timeline updates 2025-11-25 17:24:35 -07:00
Siddharth 48253cc31d file dialog choose location 2025-11-25 15:37:03 -07:00
Siddharth f887d09865 shadow intensity 2025-11-25 15:00:06 -07:00
Siddharth 6634d0eb53 editor ui improvements 2025-11-25 14:20:13 -07:00
Siddharth 6617fd39f6 configsupportcheck and throttling 2025-11-24 21:39:03 -07:00
Siddharth 8ec426d200 level 5.1 w software encoding 2025-11-24 18:49:08 -07:00
Siddharth 188ba94aad test win codec fix 2025-11-24 17:11:37 -07:00
Siddharth 7129d55f86 add support in app 2025-11-24 02:39:12 -07:00
Siddharth 483ace8e46 update fndg 2025-11-24 02:00:34 -07:00
Siddharth bf63154801 arm64 support-fix 2025-11-24 01:06:51 -07:00
Siddharth 437eded23a arm64 support 2025-11-24 01:03:23 -07:00
Siddharth 472d4053f4 arm64 support 2025-11-24 00:56:36 -07:00
Siddharth 864902b660 testing win editor issue 2025-11-24 00:44:46 -07:00
Siddharth dac826a5bc icon for win in builder 2025-11-24 00:02:15 -07:00
Siddharth dd17551c18 test actions 2025-11-23 23:56:06 -07:00
Siddharth dae7dc5212 rm uiohook-napi 2025-11-23 23:32:52 -07:00
Siddharth 210977faf4 pr template 2025-11-23 22:06:30 -07:00
Sid 9c6ac891e4 Merge pull request #2 from siddharthvaddem/v0.1.1
V0.1.1
2025-11-23 17:04:49 -07:00
Siddharth 1306c6e4ea logo update 2025-11-23 17:03:56 -07:00
Siddharth b181546ad3 reduce installer size 50% and app bundle size by 30% 2025-11-23 16:49:53 -07:00
Siddharth 0d5c4529d1 migrate to mediabunny 2025-11-23 12:24:56 -07:00
Sid 4a78bb999b Update README with macOS installation instructions 2025-11-23 00:56:35 -07:00
Sid f034c9fe01 Merge pull request #1 from siddharthvaddem/v0.1
V0.1
2025-11-23 00:53:37 -07:00
Siddharth 325c239a3a ... 2025-11-23 00:48:16 -07:00
Siddharth 9fedc5c167 3x faster exports 2025-11-23 00:33:10 -07:00
Siddharth 0e0f5003ff fix screen recording, optimize exporting pipeline 2025-11-22 23:40:39 -07:00
Siddharth 55a373c7ef recording optimizations 2025-11-22 22:28:58 -07:00
Siddharth Vaddem e14ecbff56 Modify funding sources in FUNDING.yml 2025-11-22 18:45:33 -07:00
Siddharth Vaddem 996d0d8c5b Update issue templates 2025-11-20 22:20:12 -07:00
Siddharth ba72eaf83c update readme 2025-11-20 22:09:17 -07:00
Siddharth 7f5b010c00 wallpaper fix 2025-11-20 21:22:21 -07:00
150 changed files with 30204 additions and 16939 deletions
+14
View File
@@ -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
-18
View File
@@ -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 },
],
},
}
+149
View File
@@ -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
@@ -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
+43
View File
@@ -0,0 +1,43 @@
# Pull Request Template
## Description
<!-- Briefly describe the purpose of this PR. -->
## Motivation
<!-- Explain why this change is needed. What problem does it solve? -->
## Type of Change
- [ ] New Feature
- [ ] Bug Fix
- [ ] Refactor / Code Cleanup
- [ ] Documentation Update
- [ ] Other (please specify)
## Related Issue(s)
<!-- Link to any related issue(s) (e.g., #123) -->
## Screenshots / Video
<!-- Include screenshots or a short video demonstrating the change. If the change adds a new UI feature, attach an image. If it adds functionality best shown via video, embed a video. -->
**Screenshot** (if applicable):
```markdown
![Screenshot Description](path/to/screenshot.png)
```
**Video** (if applicable):
```html
<video src="path/to/video.mp4" controls width="600"></video>
```
## Testing
<!-- Describe how reviewers can test the changes. Include steps, commands, or environment setup. -->
## Checklist
- [ ] I have performed a self-review of my code.
- [ ] I have added any necessary screenshots or videos.
- [ ] I have linked related issue(s) and updated the changelog if applicable.
---
*Thank you for contributing!*
+69 -13
View File
@@ -1,15 +1,12 @@
name: Build Electron App
on:
push:
branches: [ main, master ]
workflow_dispatch:
jobs:
build-macos:
runs-on: macos-latest
build-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
@@ -18,11 +15,6 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: npm ci
@@ -30,14 +22,78 @@ jobs:
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build Windows app
run: npm run build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Windows build
uses: actions/upload-artifact@v4
with:
name: windows-installer
path: release/**/*.exe
retention-days: 30
build-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build macOS app
run: npm run build:mac
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload macOS build
uses: actions/upload-artifact@v4
with:
name: macos-installer
path: release/**/*.dmg
retention-days: 30
retention-days: 30
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build Linux app
run: npm run build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Linux build
uses: actions/upload-artifact@v4
with:
name: linux-installer
path: release/**/*.AppImage
retention-days: 30
+44
View File
@@ -0,0 +1,44 @@
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
+2 -1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.local
@@ -23,5 +24,5 @@ dist-ssr
*.sln
*.sw?
release/**
*.kiro/
# npx electron-builder --mac --win
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+99 -70
View File
@@ -1,70 +1,99 @@
<p align="center">
<img src="openscreen.png" alt="OpenScreen Logo" width="64" />
</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, or gradients for your background
- Motion blur and exponential easing for smoother pan and zoom effects
**Note:**
- After you install the app, you'll need to grant it accessibility and screen recording permissions for it to work properly.
## macOS Installation Instructions
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
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.
## 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 🙏_
## 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.
+134
View File
@@ -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" } }
}
}
+20 -20
View File
@@ -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": {}
}
-483
View File
@@ -1,483 +0,0 @@
import { BrowserWindow, screen, ipcMain, desktopCapturer, shell, app, nativeImage, Tray, Menu } from "electron";
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs/promises";
import { uIOhook } from "uiohook-napi";
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.join(__dirname$1, "..");
const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"];
const RENDERER_DIST$1 = path.join(APP_ROOT, "dist");
function createHudOverlayWindow() {
const win = new BrowserWindow({
width: 250,
height: 80,
minWidth: 250,
maxWidth: 250,
minHeight: 80,
maxHeight: 80,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
webPreferences: {
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false
}
});
win.webContents.on("did-finish-load", () => {
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
});
if (VITE_DEV_SERVER_URL$1) {
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=hud-overlay");
} else {
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
query: { windowType: "hud-overlay" }
});
}
return win;
}
function createEditorWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
transparent: false,
resizable: true,
alwaysOnTop: false,
skipTaskbar: false,
title: "OpenScreen",
backgroundColor: "#000000",
webPreferences: {
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
webSecurity: false
}
});
win.maximize();
win.webContents.on("did-finish-load", () => {
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
});
if (VITE_DEV_SERVER_URL$1) {
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=editor");
} else {
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
query: { windowType: "editor" }
});
}
return win;
}
function createSourceSelectorWindow() {
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$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true
}
});
if (VITE_DEV_SERVER_URL$1) {
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=source-selector");
} else {
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
query: { windowType: "source-selector" }
});
}
return win;
}
let isMouseTrackingActive = false;
let isHookStarted = false;
let recordingStartTime = 0;
let mouseEventData = [];
function startMouseTracking() {
if (isMouseTrackingActive) {
return { success: false, message: "Already tracking" };
}
isMouseTrackingActive = true;
recordingStartTime = performance.now();
mouseEventData = [];
if (!isHookStarted) {
setupMouseEventListeners();
try {
uIOhook.start();
isHookStarted = true;
return { success: true, message: "Mouse tracking started", startTime: recordingStartTime };
} catch (error) {
console.error("Failed to start mouse tracking:", error);
isMouseTrackingActive = false;
return { success: false, message: "Failed to start hook", error };
}
} else {
return { success: true, message: "Mouse tracking resumed", startTime: recordingStartTime };
}
}
function stopMouseTracking() {
if (!isMouseTrackingActive) {
return { success: false, message: "Not currently tracking" };
}
isMouseTrackingActive = false;
const duration = performance.now() - recordingStartTime;
const session = {
startTime: recordingStartTime,
events: mouseEventData,
duration
};
return {
success: true,
message: "Mouse tracking stopped",
data: session
};
}
function setupMouseEventListeners() {
uIOhook.on("mousemove", (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime;
const event = {
type: "move",
timestamp,
x: e.x,
y: e.y
};
mouseEventData.push(event);
}
});
uIOhook.on("mousedown", (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime;
const event = {
type: "down",
timestamp,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
};
mouseEventData.push(event);
}
});
uIOhook.on("mouseup", (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime;
const event = {
type: "up",
timestamp,
x: e.x,
y: e.y,
button: e.button
};
mouseEventData.push(event);
}
});
uIOhook.on("click", (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime;
const event = {
type: "click",
timestamp,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
};
mouseEventData.push(event);
}
});
}
function getTrackingData() {
return [...mouseEventData];
}
function cleanupMouseTracking() {
if (isHookStarted) {
try {
uIOhook.stop();
isHookStarted = false;
isMouseTrackingActive = false;
mouseEventData = [];
} catch (error) {
console.error("Error cleaning up mouse tracking:", error);
}
}
}
let selectedSource = null;
function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) {
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("get-selected-source", () => {
return selectedSource;
});
ipcMain.handle("open-source-selector", () => {
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.focus();
return;
}
createSourceSelectorWindow2();
});
ipcMain.handle("switch-to-editor", () => {
const mainWin = getMainWindow();
if (mainWin) {
mainWin.close();
}
createEditorWindow2();
});
ipcMain.handle("start-mouse-tracking", () => {
return startMouseTracking();
});
ipcMain.handle("stop-mouse-tracking", () => {
return stopMouseTracking();
});
ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
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("store-mouse-tracking-data", async (_, fileName) => {
try {
const data = getTrackingData();
if (data.length === 0) {
return { success: false, message: "No tracking data to save" };
}
const trackingPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), "utf-8");
return {
success: true,
path: trackingPath,
eventCount: data.length,
message: "Mouse tracking data stored successfully"
};
} catch (error) {
console.error("Failed to store mouse tracking data:", error);
return {
success: false,
message: "Failed to store mouse tracking data",
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("set-recording-state", (_, recording) => {
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
}
});
ipcMain.handle("open-external-url", async (_, url) => {
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-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;
}
});
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
try {
const downloadsPath = app.getPath("downloads");
const videoPath = path.join(downloadsPath, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
return {
success: true,
path: videoPath,
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function cleanupOldRecordings() {
try {
const files = await fs.readdir(RECORDINGS_DIR);
const now = Date.now();
const maxAge = 1 * 24 * 60 * 60 * 1e3;
for (const file of files) {
const filePath = path.join(RECORDINGS_DIR, file);
const stats = await fs.stat(filePath);
if (now - stats.mtimeMs > maxAge) {
await fs.unlink(filePath);
console.log(`Deleted old recording: ${file}`);
}
}
} catch (error) {
console.error("Failed to cleanup old recordings:", error);
}
}
async function ensureRecordingsDir() {
try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
console.log("Recordings directory ready:", RECORDINGS_DIR);
} catch (error) {
console.error("Failed to create recordings directory:", error);
}
}
process.env.APP_ROOT = path.join(__dirname, "..");
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
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;
let mainWindow = null;
let sourceSelectorWindow = null;
let tray = null;
let selectedSourceName = "";
function createWindow() {
mainWindow = createHudOverlayWindow();
}
function createTray() {
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png");
let icon = nativeImage.createFromPath(iconPath);
icon = icon.resize({ width: 24, height: 24, quality: "best" });
tray = new Tray(icon);
updateTrayMenu();
}
function updateTrayMenu() {
if (!tray) return;
const menuTemplate = [
{
label: "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
}
}
];
const contextMenu = Menu.buildFromTemplate(menuTemplate);
tray.setContextMenu(contextMenu);
tray.setToolTip(`Recording: ${selectedSourceName}`);
}
function createEditorWindowWrapper() {
if (mainWindow) {
mainWindow.close();
mainWindow = null;
}
mainWindow = createEditorWindow();
}
function createSourceSelectorWindowWrapper() {
sourceSelectorWindow = createSourceSelectorWindow();
sourceSelectorWindow.on("closed", () => {
sourceSelectorWindow = null;
});
return sourceSelectorWindow;
}
app.on("window-all-closed", () => {
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
app.on("before-quit", async (event) => {
event.preventDefault();
cleanupMouseTracking();
await cleanupOldRecordings();
app.exit(0);
});
app.whenReady().then(async () => {
await ensureRecordingsDir();
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording, sourceName) => {
selectedSourceName = sourceName;
if (recording) {
if (!tray) createTray();
updateTrayMenu();
if (mainWindow) mainWindow.minimize();
} else {
if (tray) {
tray.destroy();
tray = null;
}
if (mainWindow) mainWindow.restore();
}
}
);
createWindow();
});
export {
MAIN_DIST,
RECORDINGS_DIR,
RENDERER_DIST,
VITE_DEV_SERVER_URL
};
-51
View File
@@ -1,51 +0,0 @@
"use strict";
const electron = require("electron");
electron.contextBridge.exposeInMainWorld("electronAPI", {
getAssetBasePath: async () => {
return await electron.ipcRenderer.invoke("get-asset-base-path");
},
getSources: async (opts) => {
return await electron.ipcRenderer.invoke("get-sources", opts);
},
switchToEditor: () => {
return electron.ipcRenderer.invoke("switch-to-editor");
},
openSourceSelector: () => {
return electron.ipcRenderer.invoke("open-source-selector");
},
selectSource: (source) => {
return electron.ipcRenderer.invoke("select-source", source);
},
getSelectedSource: () => {
return electron.ipcRenderer.invoke("get-selected-source");
},
startMouseTracking: () => {
return electron.ipcRenderer.invoke("start-mouse-tracking");
},
stopMouseTracking: () => {
return electron.ipcRenderer.invoke("stop-mouse-tracking");
},
storeRecordedVideo: (videoData, fileName) => {
return electron.ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
storeMouseTrackingData: (fileName) => {
return electron.ipcRenderer.invoke("store-mouse-tracking-data", fileName);
},
getRecordedVideoPath: () => {
return electron.ipcRenderer.invoke("get-recorded-video-path");
},
setRecordingState: (recording) => {
return electron.ipcRenderer.invoke("set-recording-state", recording);
},
onStopRecordingFromTray: (callback) => {
const listener = () => callback();
electron.ipcRenderer.on("stop-recording-from-tray", listener);
return () => electron.ipcRenderer.removeListener("stop-recording-from-tray", listener);
},
openExternalUrl: (url) => {
return electron.ipcRenderer.invoke("open-external-url", url);
},
saveExportedVideo: (videoData, fileName) => {
return electron.ipcRenderer.invoke("save-exported-video", videoData, fileName);
}
});
+30 -7
View File
@@ -6,12 +6,19 @@
"productName": "Openscreen",
"npmRebuild": true,
"buildDependenciesFromSource": true,
"compression": "normal",
"directories": {
"output": "release/${version}"
},
"files": [
"dist",
"dist-electron"
"dist-electron",
"!*.png",
"!preview*.png",
"!*.md",
"!README.md",
"!CONTRIBUTING.md",
"!LICENSE"
],
"extraResources": [
{
@@ -19,17 +26,33 @@
"to": "assets/wallpapers"
}
],
"mac": {
"target": [
"dmg"
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${version}-Installer.${ext}"
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
}
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSCameraUseContinuityCameraDeviceType": true
}
},
"linux": {
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}.${ext}"
"icon": "icons/icons/png",
"artifactName": "${productName}-Linux-${version}.${ext}",
"category": "AudioVideo"
},
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
}
}
+110 -50
View File
@@ -1,50 +1,110 @@
/// <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>
startMouseTracking: () => Promise<void>
stopMouseTracking: () => Promise<void>
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
storeMouseTrackingData: (fileName: string) => Promise<{ success: boolean; path?: string; eventCount?: number; 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 }>
}
}
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: 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>;
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 }>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: 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<void>) => () => void;
};
}
interface ProcessedDesktopSource {
id: string;
name: string;
display_id: string;
thumbnail: string | null;
appIcon: string | null;
}
interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
}
+520 -162
View File
@@ -1,181 +1,539 @@
import { ipcMain, desktopCapturer, BrowserWindow, shell, app } from 'electron'
import { startMouseTracking, stopMouseTracking, getTrackingData } from './mouseTracking'
import fs from 'node:fs/promises'
import path from 'node:path'
import { RECORDINGS_DIR } from '../main'
import fs from "node:fs/promises";
import path from "node:path";
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron";
import { RECORDINGS_DIR } from "../main";
let selectedSource: any = null
const PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
type SelectedSource = {
name: string;
[key: string]: unknown;
};
let selectedSource: SelectedSource | null = null;
let currentProjectPath: string | null = null;
function normalizePath(filePath: string) {
return path.resolve(filePath);
}
function isTrustedProjectPath(filePath?: string | null) {
if (!filePath || !currentProjectPath) {
return false;
}
return normalizePath(filePath) === normalizePath(currentProjectPath);
}
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("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("switch-to-editor", () => {
const mainWin = getMainWindow();
if (mainWin) {
mainWin.close();
}
createEditorWindow();
});
ipcMain.handle('start-mouse-tracking', () => {
return startMouseTracking()
})
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));
currentProjectPath = null;
ipcMain.handle('stop-mouse-tracking', () => {
return stopMouseTracking()
})
const telemetryPath = `${videoPath}.cursor.json`;
if (pendingCursorSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify(
{ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples },
null,
2,
),
"utf-8",
);
}
pendingCursorSamples = [];
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))
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)
}
}
})
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('store-mouse-tracking-data', async (_, fileName: string) => {
try {
const data = getTrackingData()
if (data.length === 0) {
return { success: false, message: 'No tracking data to save' }
}
ipcMain.handle("get-recorded-video-path", async () => {
try {
const files = await fs.readdir(RECORDINGS_DIR);
const videoFiles = files.filter((file) => file.endsWith(".webm"));
const trackingPath = path.join(RECORDINGS_DIR, fileName)
await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), 'utf-8')
return {
success: true,
path: trackingPath,
eventCount: data.length,
message: 'Mouse tracking data stored successfully'
}
} catch (error) {
console.error('Failed to store mouse tracking data:', error)
return {
success: false,
message: 'Failed to store mouse tracking data',
error: String(error)
}
}
})
if (videoFiles.length === 0) {
return { success: false, message: "No recorded video found" };
}
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) }
}
})
const latestVideo = videoFiles.sort().reverse()[0];
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
ipcMain.handle('set-recording-state', (_, recording: boolean) => {
const source = selectedSource || { name: 'Screen' }
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name)
}
})
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('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("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 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 source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
}
});
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
const downloadsPath = app.getPath('downloads')
const videoPath = path.join(downloadsPath, fileName)
await fs.writeFile(videoPath, Buffer.from(videoData))
return {
success: true,
path: videoPath,
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("get-cursor-telemetry", async (_, videoPath?: string) => {
const targetVideoPath = videoPath ?? currentVideoPath;
if (!targetVideoPath) {
return { success: true, samples: [] };
}
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
: [];
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);
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("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) };
}
});
// 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;
}
});
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"] }];
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) };
}
}
});
let currentVideoPath: string | null = null;
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" && typeof project.videoPath === "string") {
currentVideoPath = project.videoPath;
}
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" && typeof project.videoPath === "string") {
currentVideoPath = project.videoPath;
}
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-video-path", (_, path: string) => {
currentVideoPath = path;
currentProjectPath = null;
return { success: true };
});
ipcMain.handle("get-current-video-path", () => {
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
});
ipcMain.handle("clear-current-video-path", () => {
currentVideoPath = 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) };
}
});
}
-155
View File
@@ -1,155 +0,0 @@
// uiohook-napi [WIP: scoping out mouse tracking with cross platform support for potential auto zoom/ post processing cursor effects]
// not currently being used.
import { uIOhook } from 'uiohook-napi'
let isMouseTrackingActive = false
let isHookStarted = false
let recordingStartTime: number = 0
let mouseEventData: MouseEvent[] = []
export interface MouseEvent {
type: 'move' | 'down' | 'up' | 'click'
timestamp: number // milliseconds since recording started
x: number
y: number
button?: unknown
clicks?: number
}
export interface MouseTrackingSession {
startTime: number
events: MouseEvent[]
duration: number
}
export function startMouseTracking() {
if (isMouseTrackingActive) {
return { success: false, message: 'Already tracking' }
}
isMouseTrackingActive = true
// Reset data for new recording session
recordingStartTime = performance.now()
mouseEventData = []
// Only start the hook once
if (!isHookStarted) {
setupMouseEventListeners()
try {
uIOhook.start()
isHookStarted = true
return { success: true, message: 'Mouse tracking started', startTime: recordingStartTime }
} catch (error) {
console.error('Failed to start mouse tracking:', error)
isMouseTrackingActive = false
return { success: false, message: 'Failed to start hook', error }
}
} else {
return { success: true, message: 'Mouse tracking resumed', startTime: recordingStartTime }
}
}
export function stopMouseTracking(): { success: boolean; message: string; data?: MouseTrackingSession } {
if (!isMouseTrackingActive) {
return { success: false, message: 'Not currently tracking' }
}
isMouseTrackingActive = false
const duration = performance.now() - recordingStartTime
const session: MouseTrackingSession = {
startTime: recordingStartTime,
events: mouseEventData,
duration: duration
}
return {
success: true,
message: 'Mouse tracking stopped',
data: session
}
}
function setupMouseEventListeners() {
// Track mouse movement
uIOhook.on('mousemove', (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime
const event: MouseEvent = {
type: 'move',
timestamp,
x: e.x,
y: e.y
}
mouseEventData.push(event)
}
})
// Track mouse button press
uIOhook.on('mousedown', (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime
const event: MouseEvent = {
type: 'down',
timestamp,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
}
mouseEventData.push(event)
}
})
// Track mouse button release
uIOhook.on('mouseup', (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime
const event: MouseEvent = {
type: 'up',
timestamp,
x: e.x,
y: e.y,
button: e.button
}
mouseEventData.push(event)
}
})
// Track complete click events
uIOhook.on('click', (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime
const event: MouseEvent = {
type: 'click',
timestamp,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
}
mouseEventData.push(event)
}
})
}
export function getTrackingData(): MouseEvent[] {
return [...mouseEventData]
}
export function cleanupMouseTracking() {
if (isHookStarted) {
try {
uIOhook.stop()
isHookStarted = false
isMouseTrackingActive = false
mouseEventData = []
} catch (error) {
console.error('Error cleaning up mouse tracking:', error)
}
}
}
+277 -118
View File
@@ -1,43 +1,29 @@
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 { cleanupMouseTracking } from './ipc/mouseTracking'
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, 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')
// Cleanup old recordings (older than 1 day)
async function cleanupOldRecordings() {
try {
const files = await fs.readdir(RECORDINGS_DIR)
const now = Date.now()
const maxAge = 1 * 24 * 60 * 60 * 1000
for (const file of files) {
const filePath = path.join(RECORDINGS_DIR, file)
const stats = await fs.stat(filePath)
if (now - stats.mtimeMs > maxAge) {
await fs.unlink(filePath)
console.log(`Deleted old recording: ${file}`)
}
}
} catch (error) {
console.error('Failed to cleanup old recordings:', error)
}
// 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 directory ready:', RECORDINGS_DIR)
} 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
@@ -49,112 +35,285 @@ 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");
function createWindow() {
mainWindow = createHudOverlayWindow()
mainWindow = createHudOverlayWindow();
}
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() {
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'rec-button.png');
let icon = nativeImage.createFromPath(iconPath);
icon = icon.resize({ width: 24, height: 24, quality: 'best' });
tray = new Tray(icon);
updateTrayMenu();
tray = new Tray(defaultTrayIcon);
}
function updateTrayMenu() {
if (!tray) return;
const menuTemplate = [
{
label: 'Stop Recording',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('stop-recording-from-tray');
}
}
}
];
const contextMenu = Menu.buildFromTemplate(menuTemplate);
tray.setContextMenu(contextMenu);
tray.setToolTip(`Recording: ${selectedSourceName}`);
function getTrayIcon(filename: string) {
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));
}
let editorHasUnsavedChanges = false;
let isForceClosing = false;
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
});
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?",
});
if (choice === 0) {
// Save & Close — tell renderer to save, then close
mainWindow!.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", () => {
isForceClosing = true;
mainWindow?.close();
isForceClosing = false;
});
} else if (choice === 1) {
// Discard & Close
isForceClosing = true;
mainWindow?.close();
isForceClosing = false;
}
// 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("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()
}
})
// Cleanup old recordings on quit (both macOS and other platforms)
app.on('before-quit', async (event) => {
event.preventDefault()
cleanupMouseTracking()
await cleanupOldRecordings()
app.exit(0)
})
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 () => {
// Ensure recordings directory exists
await ensureRecordingsDir()
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName
if (recording) {
if (!tray) createTray();
updateTrayMenu();
if (mainWindow) mainWindow.minimize();
} else {
if (tray) {
tray.destroy();
tray = null;
}
if (mainWindow) mainWindow.restore();
}
}
)
createWindow()
})
// 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) {
if (mainWindow) mainWindow.restore();
}
},
);
createWindow();
});
+115 -51
View File
@@ -1,52 +1,116 @@
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld('electronAPI', {
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')
},
startMouseTracking: () => {
return ipcRenderer.invoke('start-mouse-tracking')
},
stopMouseTracking: () => {
return ipcRenderer.invoke('stop-mouse-tracking')
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke('store-recorded-video', videoData, fileName)
},
storeMouseTrackingData: (fileName: string) => {
return ipcRenderer.invoke('store-mouse-tracking-data', fileName)
},
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)
},
})
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");
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
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);
},
getCurrentVideoPath: () => {
return ipcRenderer.invoke("get-current-video-path");
},
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<void>) => {
const listener = async () => {
await callback();
ipcRenderer.send("save-before-close-done");
};
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
},
});
+130 -99
View File
@@ -1,120 +1,151 @@
import { BrowserWindow, screen } 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");
let hudOverlayWindow: BrowserWindow | null = null;
ipcMain.on("hud-overlay-hide", () => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
}
});
export function createHudOverlayWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 250,
height: 80,
minWidth: 250,
maxWidth: 250,
minHeight: 80,
maxHeight: 80,
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,
},
})
const primaryDisplay = screen.getPrimaryDisplay();
const { workArea } = primaryDisplay;
const windowWidth = 500;
const windowHeight = 155;
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', (new Date).toLocaleString())
})
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
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' }
})
}
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,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
},
});
return win
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
hudOverlayWindow = win;
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;
}
export function createEditorWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
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,
},
})
const isMac = process.platform === "darwin";
// Maximize the window by default
win.maximize();
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,
},
});
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', (new Date).toLocaleString())
})
// Maximize the window by default
win.maximize();
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' }
})
}
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
return win
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;
}
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;
}
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 798 KiB

After

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 660 B

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 353 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 KiB

+14426 -10442
View File
File diff suppressed because it is too large Load Diff
+87 -58
View File
@@ -1,59 +1,88 @@
{
"name": "openscreen",
"private": true,
"version": "0.0.0",
"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"
},
"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-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",
"@uiw/react-color-colorful": "^2.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"lucide-react": "^0.545.0",
"mp4-muxer": "^5.2.2",
"mp4box": "^2.2.0",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uiohook-napi": "^1.5.4"
},
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"electron": "^30.0.1",
"electron-builder": "^24.13.3",
"electron-rebuild": "^3.2.9",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.2.2",
"vite": "^5.1.6",
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5"
},
"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",
"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",
"@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",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@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"
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 KiB

-34
View File
@@ -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

-26
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 MiB

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 MiB

After

Width:  |  Height:  |  Size: 897 KiB

BIN
View File
Binary file not shown.
+42 -42
View File
@@ -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;
}
+47 -34
View File
@@ -1,34 +1,47 @@
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 { 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);
});
}, []);
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>
);
}
}
+129 -2
View File
@@ -1,6 +1,133 @@
.electronDrag {
-webkit-app-region: drag;
-webkit-app-region: drag;
}
.electronNoDrag {
-webkit-app-region: no-drag;
-webkit-app-region: no-drag;
}
.hudBar {
isolation: isolate;
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.25),
0 0 40px rgba(100, 80, 200, 0.08);
}
/* Sub-pill group container */
.hudGroup {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.05);
border-radius: 9999px;
padding: 4px 8px;
transition: background 0.15s ease;
}
.hudGroup:hover {
background: rgba(255, 255, 255, 0.08);
}
/* Icon button within groups */
.hudIconBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 9999px;
transition: all 0.15s ease;
cursor: pointer;
background: transparent;
border: none;
color: #fff;
}
.hudIconBtn:hover {
background: rgba(255, 255, 255, 0.1);
transform: scale(1.08);
}
.hudIconBtn:active {
transform: scale(0.95);
}
/* Active icon glow (green) for enabled audio toggles */
.hudIconActive {
filter: drop-shadow(0 0 4px rgba(74, 222, 128, 0.4));
}
/* Recording pulse animation on the record group */
@keyframes recordPulse {
0%,
100% {
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
}
50% {
box-shadow: 0 0 16px rgba(239, 68, 68, 0.4);
}
}
.recordingPulse {
animation: recordPulse 1.5s ease-in-out infinite;
background: rgba(239, 68, 68, 0.1) !important;
}
/* Mic panel above the bar */
.micPanel {
background: linear-gradient(135deg, rgba(28, 28, 36, 0.97) 0%, rgba(18, 18, 26, 0.96) 100%);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
border: 1px solid rgba(80, 80, 120, 0.25);
border-radius: 16px;
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.2),
0 0 30px rgba(100, 80, 200, 0.06);
animation: micPanelIn 0.15s ease-out;
}
@keyframes micPanelIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Window control buttons */
.windowBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
border-radius: 9999px;
transition: all 0.15s ease;
cursor: pointer;
background: transparent;
border: none;
opacity: 0.5;
}
.windowBtn:hover {
opacity: 0.9;
background: rgba(255, 255, 255, 0.08);
}
/* Folder button */
.folderButton {
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.folderText {
color: #cbd5e1;
transition: text-decoration 0.15s;
}
.folderButton:hover .folderText {
text-decoration: underline;
}
+260 -81
View File
@@ -1,93 +1,272 @@
import { useState, useEffect } from "react";
import styles from "./LaunchWindow.module.css";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { Button } from "../ui/button";
import { useEffect, useState } from "react";
import { BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { MdMonitor } from "react-icons/md";
import { FaFolderOpen } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import { MdMic, MdMicOff, MdMonitor, 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 { AudioLevelMeter } from "../ui/audio-level-meter";
import styles from "./LaunchWindow.module.css";
export function LaunchWindow() {
const { recording, toggleRecording } = useScreenRecorder();
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const {
recording,
toggleRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
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 showMicControls = microphoneEnabled && !recording;
const { devices, selectedDeviceId, setSelectedDeviceId } =
useMicrophoneDevices(microphoneEnabled);
const { level } = useAudioLevelMeter({
enabled: showMicControls,
deviceId: microphoneDeviceId,
});
checkSelectedSource();
const interval = setInterval(checkSelectedSource, 500);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (selectedDeviceId && selectedDeviceId !== "default") {
setMicrophoneDeviceId(selectedDeviceId);
}
}, [selectedDeviceId, setMicrophoneDeviceId]);
const truncateText = (text: string, maxLength: number = 6) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
};
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 openSourceSelector = () => {
if (window.electronAPI) {
window.electronAPI.openSourceSelector();
}
};
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);
return (
<div className="w-full h-full flex items-center bg-transparent">
<div
className={`w-full max-w-2xl mx-auto flex items-center justify-between px-3 py-1.5 ${styles.electronDrag}`}
style={{
borderRadius: 14,
background: 'linear-gradient(135deg, rgba(30,30,40,0.85) 0%, rgba(20,20,30,0.75) 100%)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
boxShadow: '0 4px 16px 0 rgba(0,0,0,0.24), 0 1px 3px 0 rgba(0,0,0,0.12) inset',
border: '1px solid rgba(80,80,120,0.18)',
minHeight: 36,
}}
>
<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}
>
<MdMonitor size={13} className="text-white" />
{truncateText(selectedSource)}
</Button>
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);
}
}
};
<div className="w-px h-5 bg-white/30" />
checkSelectedSource();
<Button
variant="link"
size="sm"
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
className={`gap-1 bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag}`}
>
{recording ? (
<>
<FaRegStopCircle size={13} className="text-red-400" />
<span className="text-red-400">Stop</span>
</>
) : (
<>
<BsRecordCircle size={13} className={hasSelectedSource ? "text-white" : "text-white/50"} />
<span className={hasSelectedSource ? "text-white" : "text-white/50"}>Record</span>
</>
)}
</Button>
</div>
</div>
);
const interval = setInterval(checkSelectedSource, 500);
return () => clearInterval(interval);
}, []);
const openSourceSelector = () => {
if (window.electronAPI) {
window.electronAPI.openSourceSelector();
}
};
const openVideoFile = async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (result.canceled) {
return;
}
if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
await window.electronAPI.switchToEditor();
}
};
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();
}
};
const toggleMicrophone = () => {
if (!recording) {
setMicrophoneEnabled(!microphoneEnabled);
}
};
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 ${styles.micPanel} ${styles.electronNoDrag}`}
>
<select
value={microphoneDeviceId || selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className="flex-1 bg-white/10 text-white text-xs rounded-full px-3 py-1 border border-white/20 outline-none truncate"
style={{ maxWidth: "70%" }}
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<AudioLevelMeter level={level} className="w-24 h-4" />
</div>
)}
{/* Main pill bar */}
<div
className={`flex items-center gap-1.5 px-2 py-1.5 ${styles.hudBar}`}
style={{
borderRadius: 9999,
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
backdropFilter: "blur(16px) saturate(140%)",
WebkitBackdropFilter: "blur(16px) saturate(140%)",
border: "1px solid rgba(80,80,120,0.25)",
}}
>
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
<RxDragHandleDots2 size={16} className="text-white/30" />
</div>
{/* Source selector */}
<button
className={`${styles.hudGroup} ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
<MdMonitor size={14} className="text-white/80" />
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
{selectedSource}
</span>
</button>
{/* Audio controls group */}
<div className={`${styles.hudGroup} ${styles.electronNoDrag}`}>
<button
className={`${styles.hudIconBtn} ${systemAudioEnabled ? styles.hudIconActive : ""}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
>
{systemAudioEnabled ? (
<MdVolumeUp size={15} className="text-green-400" />
) : (
<MdVolumeOff size={15} className="text-white/40" />
)}
</button>
<button
className={`${styles.hudIconBtn} ${microphoneEnabled ? styles.hudIconActive : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
>
{microphoneEnabled ? (
<MdMic size={15} className="text-green-400" />
) : (
<MdMicOff size={15} className="text-white/40" />
)}
</button>
</div>
{/* Record/Stop group */}
<button
className={`${styles.hudGroup} ${styles.electronNoDrag} ${recording ? styles.recordingPulse : ""}`}
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
{recording ? (
<>
<FaRegStopCircle size={13} className="text-red-400" />
<span className="text-red-400 text-xs font-semibold tabular-nums">
{formatTime(elapsed)}
</span>
</>
) : (
<BsRecordCircle
size={14}
className={hasSelectedSource ? "text-white/80" : "text-white/30"}
/>
)}
</button>
{/* Open video file */}
<button
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
title="Open video file"
>
<MdVideoFile size={14} className="text-white/60" />
</button>
{/* Open project */}
<button
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
title="Open project"
>
<FaFolderOpen size={14} className="text-white/60" />
</button>
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button className={styles.windowBtn} title="Hide HUD" onClick={sendHudOverlayHide}>
<FiMinus size={14} className="text-white" />
</button>
<button className={styles.windowBtn} title="Close App" onClick={sendHudOverlayClose}>
<FiX size={14} className="text-white" />
</button>
</div>
</div>
</div>
</div>
);
}
+82 -24
View File
@@ -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);
}
+144 -145
View File
@@ -1,159 +1,158 @@
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-60 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="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
</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
</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>
);
}
+55
View File
@@ -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 };
+37
View File
@@ -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>
);
}
+41 -48
View File
@@ -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 };
+47 -68
View File
@@ -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 };
+81
View File
@@ -0,0 +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 };
+81 -99
View File
@@ -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,
};
+152 -165
View File
@@ -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,
};
+23
View File
@@ -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 };
+3 -2
View File
@@ -7,11 +7,12 @@ interface ItemContentProps extends PropsWithChildren {
function ItemContent({ children, classes }: ItemContentProps) {
return (
<div
className={`border-2 rounded-sm shadow-md w-full overflow-hidden flex flex-row pl-3 h-items-center ${classes}`}
className={`bg-white/5 border border-white/10 rounded-md shadow-sm w-full flex flex-row items-center px-3 py-1 gap-2 transition-all duration-150 hover:bg-[#34B27B]/10 hover:shadow-lg ${classes}`}
style={{ minHeight: 40 }}
>
{children}
</div>
);
}
export default ItemContent;
export default ItemContent;
+20
View File
@@ -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 };
+60
View File
@@ -0,0 +1,60 @@
"use client";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@/lib/utils";
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 PopoverContent({
className,
align = "center",
sideOffset = 4,
animated = true,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
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>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverArrow({
className,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn("fill-popover", className)}
{...props}
/>
);
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow };
+153
View File
@@ -0,0 +1,153 @@
"use client";
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";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
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;
const SelectScrollUpButton = React.forwardRef<
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;
const SelectScrollDownButton = React.forwardRef<
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;
const SelectContent = React.forwardRef<
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;
const SelectLabel = React.forwardRef<
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;
const SelectItem = React.forwardRef<
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.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>
>(({ className, ...props }, ref) => (
<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,
};
+18 -21
View File
@@ -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-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring 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 };
+17 -19
View File
@@ -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 };
+25 -25
View File
@@ -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 };
+41 -41
View File
@@ -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 };
+56
View File
@@ -0,0 +1,56 @@
"use client";
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";
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>
>(({ 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>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
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>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
+43
View File
@@ -0,0 +1,43 @@
"use client";
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";
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",
},
},
);
const Toggle = React.forwardRef<
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}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };
@@ -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>
);
}
@@ -0,0 +1,220 @@
import { useRef } from "react";
import { Rnd } from "react-rnd";
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
}
export function AnnotationOverlay({
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 isDraggingRef = useRef(false);
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 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 "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>
);
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>
);
}
@@ -0,0 +1,611 @@
import Block from "@uiw/react-color-block";
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
ChevronDown,
Image as ImageIcon,
Info,
Italic,
Trash2,
Type,
Underline,
Upload,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
onContentChange: (content: string) => void;
onTypeChange: (type: AnnotationType) => void;
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
onFigureDataChange?: (figureData: FigureData) => void;
onDelete: () => void;
}
const FONT_FAMILIES = [
{ value: "system-ui, -apple-system, sans-serif", label: "Classic" },
{ value: "Georgia, serif", label: "Editor" },
{ value: "Impact, Arial Black, sans-serif", label: "Strong" },
{ value: "Courier New, monospace", label: "Typewriter" },
{ value: "Brush Script MT, cursive", label: "Deco" },
{ value: "Arial, sans-serif", label: "Simple" },
{ value: "Verdana, sans-serif", label: "Modern" },
{ value: "Trebuchet MS, sans-serif", label: "Clean" },
];
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
export function AnnotationSettingsPanel({
annotation,
onContentChange,
onTypeChange,
onStyleChange,
onFigureDataChange,
onDelete,
}: AnnotationSettingsPanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
// Load custom fonts on mount
useEffect(() => {
setCustomFonts(getCustomFonts());
}, []);
const colorPalette = [
"#FF0000", // Red
"#FFD700", // Yellow/Gold
"#00FF00", // Green
"#FFFFFF", // White
"#0000FF", // Blue
"#FF6B00", // Orange
"#9B59B6", // Purple
"#E91E63", // Pink
"#00BCD4", // Cyan
"#FF5722", // Deep Orange
"#8BC34A", // Light Green
"#FFC107", // Amber
"#34B27B", // Brand Green
"#000000", // Black
"#607D8B", // Blue Grey
"#795548", // Brown
];
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// Validate file type
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
if (!validTypes.includes(file.type)) {
toast.error("Invalid file type", {
description: "Please upload a JPG, PNG, GIF, or WebP image file.",
});
event.target.value = "";
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
onContentChange(dataUrl);
toast.success("Image uploaded successfully!");
}
};
reader.onerror = () => {
toast.error("Failed to upload image", {
description: "There was an error reading the file.",
});
};
reader.readAsDataURL(file);
event.target.value = "";
};
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
Active
</span>
</div>
{/* Type Selector */}
<Tabs
value={annotation.type}
onValueChange={(value) => onTypeChange(value as AnnotationType)}
className="mb-6"
>
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<TabsTrigger
value="text"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
>
<Type className="w-4 h-4" />
Text
</TabsTrigger>
<TabsTrigger
value="image"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
>
<ImageIcon className="w-4 h-4" />
Image
</TabsTrigger>
<TabsTrigger
value="figure"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Arrow
</TabsTrigger>
</TabsList>
{/* Text Content */}
<TabsContent value="text" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
<textarea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder="Enter your text..."
rows={5}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
/>
</div>
{/* Styling Controls */}
<div className="space-y-4">
{/* Font Family & Size */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Font Style
</label>
<Select
value={annotation.style.fontFamily}
onValueChange={(value) => onStyleChange({ fontFamily: value })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Select style" />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
{FONT_FAMILIES.map((font) => (
<SelectItem
key={font.value}
value={font.value}
style={{ fontFamily: font.value }}
>
{font.label}
</SelectItem>
))}
{customFonts.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
Custom Fonts
</div>
{customFonts.map((font) => (
<SelectItem
key={font.id}
value={font.fontFamily}
style={{ fontFamily: font.fontFamily }}
>
{font.name}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
<Select
value={annotation.style.fontSize.toString()}
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Size" />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
{FONT_SIZES.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}px
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Add Custom Font Button */}
<div>
<AddCustomFontDialog
onFontAdded={(font) => {
setCustomFonts(getCustomFonts());
onStyleChange({ fontFamily: font.fontFamily });
}}
/>
</div>
{/* Formatting Toggles */}
<div className="flex items-center justify-between gap-2">
<ToggleGroup
type="multiple"
className="justify-start bg-white/5 p-1 rounded-lg border border-white/5"
>
<ToggleGroupItem
value="bold"
aria-label="Toggle bold"
data-state={annotation.style.fontWeight === "bold" ? "on" : "off"}
onClick={() =>
onStyleChange({
fontWeight: annotation.style.fontWeight === "bold" ? "normal" : "bold",
})
}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<Bold className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
aria-label="Toggle italic"
data-state={annotation.style.fontStyle === "italic" ? "on" : "off"}
onClick={() =>
onStyleChange({
fontStyle: annotation.style.fontStyle === "italic" ? "normal" : "italic",
})
}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<Italic className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="underline"
aria-label="Toggle underline"
data-state={annotation.style.textDecoration === "underline" ? "on" : "off"}
onClick={() =>
onStyleChange({
textDecoration:
annotation.style.textDecoration === "underline" ? "none" : "underline",
})
}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
type="single"
value={annotation.style.textAlign}
className="justify-start bg-white/5 p-1 rounded-lg border border-white/5"
>
<ToggleGroupItem
value="left"
aria-label="Align left"
onClick={() => onStyleChange({ textAlign: "left" })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<AlignLeft className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="center"
aria-label="Align center"
onClick={() => onStyleChange({ textAlign: "center" })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<AlignCenter className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="right"
aria-label="Align right"
onClick={() => onStyleChange({ textAlign: "right" })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<AlignRight className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Text Color
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
>
<div
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: annotation.style.color }}
/>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.style.color}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={annotation.style.color}
colors={colorPalette}
onChange={(color) => {
onStyleChange({ color: color.hex });
}}
style={{
borderRadius: "8px",
}}
/>
</PopoverContent>
</Popover>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Background
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
>
<div className="w-4 h-4 rounded-full border border-white/20 relative overflow-hidden">
<div className="absolute inset-0 checkerboard-bg opacity-50" />
<div
className="absolute inset-0"
style={{ backgroundColor: annotation.style.backgroundColor }}
/>
</div>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.style.backgroundColor === "transparent" ? "None" : "Color"}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={
annotation.style.backgroundColor === "transparent"
? "#000000"
: annotation.style.backgroundColor
}
colors={colorPalette}
onChange={(color) => {
onStyleChange({ backgroundColor: color.hex });
}}
style={{
borderRadius: "8px",
}}
/>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
onClick={() => {
onStyleChange({ backgroundColor: "transparent" });
}}
>
Clear Background
</Button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</TabsContent>
{/* Image Upload */}
<TabsContent value="image" className="mt-0 space-y-4">
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept=".jpg,.jpeg,.png,.gif,.webp,image/*"
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
>
<Upload className="w-5 h-5" />
Upload Image
</Button>
{annotation.content && annotation.content.startsWith("data:image") && (
<div className="rounded-lg border border-white/10 overflow-hidden bg-white/5 p-2">
<img
src={annotation.content}
alt="Uploaded annotation"
className="w-full h-auto rounded-md"
/>
</div>
)}
<p className="text-xs text-slate-500 text-center leading-relaxed">
Supported formats: JPG, PNG, GIF, WebP
</p>
</TabsContent>
<TabsContent value="figure" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-3 block">
Arrow Direction
</label>
<div className="grid grid-cols-4 gap-2">
{(
[
"up",
"down",
"left",
"right",
"up-right",
"up-left",
"down-right",
"down-left",
] as ArrowDirection[]
).map((direction) => {
const ArrowComponent = getArrowComponent(direction);
return (
<button
key={direction}
onClick={() => {
const newFigureData: FigureData = {
...annotation.figureData!,
arrowDirection: direction,
};
onFigureDataChange?.(newFigureData);
}}
className={cn(
"h-16 rounded-lg border flex items-center justify-center transition-all p-2",
annotation.figureData?.arrowDirection === direction
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
)}
>
<ArrowComponent
color={
annotation.figureData?.arrowDirection === direction
? "#ffffff"
: "#94a3b8"
}
strokeWidth={3}
/>
</button>
);
})}
</div>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
</label>
<Slider
value={[annotation.figureData?.strokeWidth || 4]}
onValueChange={([value]) => {
const newFigureData: FigureData = {
...annotation.figureData!,
strokeWidth: value,
};
onFigureDataChange?.(newFigureData);
}}
min={1}
max={6}
step={1}
className="w-full"
/>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-10 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10"
>
<div
className="w-5 h-5 rounded-full border border-white/20"
style={{ backgroundColor: annotation.figureData?.color || "#34B27B" }}
/>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.figureData?.color || "#34B27B"}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={annotation.figureData?.color || "#34B27B"}
colors={colorPalette}
onChange={(color) => {
const newFigureData: FigureData = {
...annotation.figureData!,
color: color.hex,
};
onFigureDataChange?.(newFigureData);
}}
style={{
borderRadius: "8px",
}}
/>
</PopoverContent>
</Popover>
</div>
</TabsContent>
</Tabs>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
Delete Annotation
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">Shortcuts & Tips</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>Move playhead to overlapping annotation section and select an item.</li>
<li>
Use{" "}
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to
cycle through overlapping items.
</li>
<li>
Use{" "}
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">
Shift+Tab
</kbd>{" "}
to cycle backwards.
</li>
</ul>
</div>
</div>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
import type { ArrowDirection } from "./types";
interface ArrowSvgProps {
color: string;
strokeWidth: number;
className?: string;
}
/**
* Inline SVG arrow components for 8 directions.
* These match the visual style of the previous icon-based arrows but use
* pure SVG paths for easy replication in export.
*/
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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;
}
}
+202 -190
View File
@@ -1,221 +1,233 @@
import { useEffect, useRef, useState } from "react";
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;
videoElement: HTMLVideoElement | null;
cropRegion: CropRegion;
onCropChange: (region: CropRegion) => void;
aspectRatio: AspectRatio;
}
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
type DragHandle = "top" | "right" | "bottom" | "left" | 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;
}
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 handlePointerUp = (e: React.PointerEvent) => {
if (isDragging) {
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
}
setIsDragging(null);
};
return (
<div className="w-full p-8">
<div
ref={containerRef}
className="relative w-full aspect-video bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
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={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 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 + 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>
);
}
+256 -140
View File
@@ -1,155 +1,271 @@
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 { toast } from "sonner"; // Add this import
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;
isOpen: boolean;
onClose: () => void;
progress: ExportProgress | null;
isExporting: boolean;
error: string | null;
onCancel?: () => void;
exportFormat?: "mp4" | "gif";
exportedFilePath?: string;
}
export function ExportDialog({
isOpen,
onClose,
progress,
isExporting,
error,
onCancel,
isOpen,
onClose,
progress,
isExporting,
error,
onCancel,
exportFormat = "mp4",
exportedFilePath, // Add this line
}: 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]);
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-8">
<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 video 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">
{error ? 'Export Failed' : isExporting ? 'Exporting Video' : 'Export Video'}
</span>
<span className="text-sm text-slate-400">
{error ? 'Please try again' : isExporting ? 'This may take a moment...' : 'Ready to start'}
</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>
useEffect(() => {
if (!isExporting && progress && progress.percentage >= 100 && !error) {
setShowSuccess(true);
const timer = setTimeout(() => {
setShowSuccess(false);
onClose();
}, 2000);
return () => clearTimeout(timer);
}
}, [isExporting, progress, error, onClose]);
{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>
)}
if (!isOpen) return null;
{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>Progress</span>
<span className="font-mono text-slate-200">{progress.percentage.toFixed(0)}%</span>
</div>
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
<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>
const formatLabel = exportFormat === "gif" ? "GIF" : "Video";
<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">Current Frame</div>
<div className="text-slate-200 font-mono text-lg font-medium">
{progress.currentFrame} <span className="text-slate-500 text-sm">/ {progress.totalFrames}</span>
</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">Status</div>
<div className="text-slate-200 font-medium text-sm flex items-center gap-2 h-[28px]">
<span className="w-2 h-2 rounded-full bg-[#34B27B] animate-pulse" />
Processing
</div>
</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;
{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>
)}
// 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...";
};
{showSuccess && (
<div className="text-center py-4 animate-in zoom-in-95">
<p className="text-lg text-slate-200 font-medium">Video saved successfully!</p>
</div>
)}
</div>
</>
);
// Get title based on phase
const getTitle = () => {
if (error) return "Export Failed";
if (isCompiling || isFinalizing) return "Compiling GIF";
return `Exporting ${formatLabel}`;
};
const handleClickShowInFolder = async () => {
if (exportedFilePath) {
try {
const result = await window.electronAPI.revealInFolder(exportedFilePath);
if (!result.success) {
const errorMessage = result.error || result.message || "Failed to reveal item in folder.";
console.error("Failed to reveal in folder:", errorMessage);
toast.error(errorMessage);
}
} catch (err) {
const errorMessage = String(err);
console.error("Error calling revealInFolder IPC:", errorMessage);
toast.error(`Error revealing in folder: ${errorMessage}`);
}
}
};
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={handleClickShowInFolder}
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 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>
{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>
</>
);
}
@@ -0,0 +1,77 @@
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;
}
interface FormatOption {
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" />,
},
];
export function FormatSelector({
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>
);
}
@@ -0,0 +1,111 @@
import {
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;
}
export function GifOptionsPanel({
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,
}));
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>
{/* 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>
);
}
@@ -0,0 +1,74 @@
import { HelpCircle, Settings2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
import { formatShortcut } from "@/utils/platformUtils";
export function KeyboardShortcutsHelp() {
const { shortcuts, isMac, openConfig } = useShortcuts();
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
zoom: "Ctrl + Scroll",
});
useEffect(() => {
Promise.all([
formatShortcut(["shift", "mod", "Scroll"]),
formatShortcut(["mod", "Scroll"]),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
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="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">
<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">
{scrollLabels.pan}
</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<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">
{scrollLabels.zoom}
</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Cycle Annotations</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
Tab
</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-3 px-4 py-2 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-[10px] font-medium text-slate-300 tabular-nums w-[35px] 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-[10px] font-medium text-slate-500 tabular-nums w-[35px]">
{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>
);
}
File diff suppressed because it is too large Load Diff
@@ -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]);
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>
);
}
@@ -0,0 +1,125 @@
import { ArrowRight, HelpCircle, Scissors } from "lucide-react";
import { Button } from "@/components/ui/button";
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>
);
}

Some files were not shown because too many files have changed in this diff Show More