Compare commits

..
Author SHA1 Message Date
Siddharth 7169e583c7 revert: undo local merge of PR #347 2026-04-11 09:58:15 -07:00
Siddharth de7518549c feat: add dual frame webcam layout preset (#347) 2026-04-11 09:54:30 -07:00
Shreyas b1a1f45e93 refactor: simplify dual frame preset normalization 2026-04-11 09:30:01 -07:00
Shreyas bce1957505 fix: clear webcam position for non-pip layouts 2026-04-11 09:30:00 -07:00
Shreyas 24b4b4254a fix: normalize dual frame preset for portrait projects 2026-04-11 09:26:15 -07:00
Shreyas 16cba73cb2 fix: avoid double-scaling dual frame export radius 2026-04-11 09:26:15 -07:00
Shreyas c55f462f1c feat: add dual frame webcam layout preset 2026-04-11 09:20:34 -07:00
Sid 68295b21ec Merge pull request #394 from LorenzoLancia/feature/blur-selection
feat: add blur selection (rectangle, oval)
2026-04-10 07:10:29 -07:00
LorenzoLancia 3232918197 Add the Shortcut Blur 2026-04-09 21:51:27 +02:00
LorenzoLancia 38d72217c2 fix little things blur 2026-04-08 22:43:30 +02:00
LorenzoLancia f6b7c463f0 Fix last issues 2026-04-08 22:21:19 +02:00
LorenzoLancia f8232d9c76 Fix some little issues 2026-04-08 21:36:53 +02:00
LorenzoLancia 5a9c85c345 Fix formatting and locale config 2026-04-08 20:26:16 +02:00
Lorenzo Lancia a4f1c6a2ee feat: add blur selection (rectangle, oval) 2026-04-08 16:42:12 +02:00
Sid e7d5f51740 Merge pull request #345 from GarryLaly/feature/webcam-resize-slider
feat: Add webcam size with slider
2026-04-07 22:40:15 -07:00
Sid 9e6b05815f Merge pull request #375 from mehmetnadir/feat/turkish-locale
feat(i18n): add Turkish (tr) locale support
2026-04-07 22:30:30 -07:00
Sid 7bd993a97b Merge branch 'main' into feat/turkish-locale 2026-04-07 22:30:16 -07:00
Sid 558379702a Merge pull request #330 from maxbailey/main
fix: resolve green MP4 exports on CachyOS/Arch Linux (Wayland)
2026-04-07 22:28:00 -07:00
Sid 09b99563f5 Merge pull request #380 from FabLrc/french-traduction
feat(i18n): add French translations
2026-04-07 22:21:34 -07:00
Sid b34961f6af Merge pull request #365 from AmitwalaH/fix-tutorial-translations
fix(i18n): add missing tutorial dialog translation keys
2026-04-07 22:21:02 -07:00
Sid 5a36179454 Merge pull request #383 from marcgabe15/exportTesting
feat: Add unit tests for exporting videos
2026-04-07 22:02:17 -07:00
Marc Diaz 3482be9864 refactor: remove extraneous comments 2026-04-07 13:50:26 -04:00
Marc Diaz b8fe1a1ec8 fix(playwright): use one version 2026-04-07 13:32:49 -04:00
Marc Diaz 33609432e1 fix: use npm for install 2026-04-07 13:05:27 -04:00
Marc Diaz b65c68d139 fix: use headless 2026-04-07 13:02:11 -04:00
Marc Diaz 6bff2a2a2c feat: use export testing 2026-04-07 12:58:33 -04:00
FabLrc 1f56bb42c3 fix(i18n): update French translations for cycle annotations shortcuts 2026-04-07 12:17:53 +02:00
FabLrc 7a8fb807e6 feat(i18n): add French translations for common and dialogs namespaces 2026-04-07 12:17:10 +02:00
Garry Priambudi 0e1a69a7b2 Merge branch 'main' into feature/webcam-resize-slider 2026-04-07 17:13:38 +07:00
FabLrc e739653b3f feat(i18n): add French translations for various application components 2026-04-07 12:05:36 +02:00
Sid 9024eaae61 Merge pull request #307 from Ayush765-spec/main
Added the new recording button so that user does not exit the entire application
2026-04-06 23:00:24 -07:00
Sid c5882b06b1 Merge pull request #334 from matthew-hre/matthew-hre/jj-przmrvurqkow
fix: handle av1 VideoDecoder errors
2026-04-06 22:47:50 -07:00
Sid 306b61a902 Merge pull request #291 from 1shanpanta/feat/extended-speed-options
feat: extend speed options with higher presets and custom speed input
2026-04-06 22:14:08 -07:00
Nadir A.andClaude Opus 4.6 c36349d950 feat(i18n): add Turkish (tr) locale support
Add complete Turkish translation across all 7 i18n namespaces:
- common: actions, playback controls, locale metadata
- launch: HUD tooltips, audio/webcam controls, source selector
- editor: error messages, export, project, recording permissions
- dialogs: export progress, trim tutorial, unsaved changes, file dialogs
- settings: all panels (zoom, speed, trim, layout, effects, background,
  crop, export, annotations, custom fonts, language, audio)
- shortcuts: keyboard shortcuts panel and all actions
- timeline: toolbar buttons, hints, labels, errors, success messages

Also adds "tr" to SUPPORTED_LOCALES config and i18n validation script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:05:21 +03:00
AmitwalaH 4e2a53b200 fix: spacing issues in tutorial translations 2026-04-06 15:19:24 +05:30
AmitwalaH 90ba713323 fix(i18n): update tutorial dialog translation keys for all locales 2026-04-06 15:08:49 +05:30
Sid 24928164ca Merge pull request #355 from getSono/main
Adding automatic debian builds
2026-04-05 22:56:56 -07:00
Garry Priambudi 5320f76aae Merge branch 'main' into feature/webcam-resize-slider 2026-04-06 07:56:28 +07:00
Sid e571ecbf8d Merge pull request #352 from siddharthvaddem/sid/fix-read-handler-security
fix(security): prevent path traversal in IPC file read handlers
2026-04-05 17:04:27 -07:00
Siddharth d4c50c9a5e ci: remove flaky e2e test job from CI pipeline 2026-04-05 16:48:53 -07:00
Siddharth 3e6dff9c34 fix: wrap evaluate in try/catch for expected HUD window close
The HUD window now closes faster after switchToEditor, causing the
Playwright page context to terminate before evaluate returns.
2026-04-05 16:34:35 -07:00
Siddharth 1b6f4cce46 fix: restore original e2e test with minimal security fix additions
Revert to exact working version (7e65d52), only adding:
- recordings dir copy for path security check
- --enable-unsafe-swiftshader for CI WebGL
2026-04-05 16:29:54 -07:00
Julian Wolf ec946f0807 Merge branch 'siddharthvaddem:main' into main 2026-04-06 01:26:58 +02:00
Julian Wolf 37e1a82d05 Update ci.yml 2026-04-06 01:26:43 +02:00
Sid 2ce4dc49b3 Merge pull request #354 from notrudyyy/patch-1
Improve grammar and mobile images in README
2026-04-05 16:23:53 -07:00
Siddharth db815e362a ci: trigger checks 2026-04-05 16:22:22 -07:00
Siddharth ed9b8689f7 fix: catch expected page close error in e2e test evaluate call
switchToEditor closes the HUD window, which terminates the Playwright
page context before evaluate can return. Catch at the outer level.
2026-04-05 16:20:29 -07:00
Siddharth dc0856282f fix: add --enable-unsafe-swiftshader to e2e test for CI WebGL support
The headless CI environment fails to create valid WebGL framebuffers,
causing PixiJS pixel reads to fail silently and GIF export to hang.
SwiftShader provides a software WebGL implementation that works reliably.
2026-04-05 16:14:34 -07:00
Anirudh Vempati da79dab756 Update preview image sizes to be dynamic 2026-04-06 04:37:49 +05:30
Siddharth 1dc2c06ee4 fix: revert e2e test to fire-and-forget setCurrentVideoPath with reload
Restore the original test approach that was passing: fire-and-forget
setCurrentVideoPath, catch the switchToEditor context close, and reload
the editor window for WebCodecs initialization.
2026-04-05 16:04:01 -07:00
Anirudh Vempati b6803eb6e3 Update README.md 2026-04-06 04:28:15 +05:30
Siddharth 8013cc97bb fix: remove editor reload in e2e test that was clearing video state
The reload was intended to ensure WebCodecs registered, but it clears
the video path state set before the editor opened, causing the editor
to load blank and the export to never complete.
2026-04-05 15:56:28 -07:00
Siddharth e45611ade4 fix: e2e test — copy fixture into recordings dir for path security check
The test fixture path is outside RECORDINGS_DIR, so set-current-video-path
rejects it after the path traversal fix. Copy the fixture into the app
recordings directory before loading it.
2026-04-05 15:42:25 -07:00
Siddharth b986148d5d fix exporter test 2026-04-05 15:36:29 -07:00
Siddharth fe0c2829a7 fix 2026-04-05 15:33:39 -07:00
Siddharth e4672811de fix(security): prevent path traversal in IPC file read handlers 2026-04-05 14:58:28 -07:00
Julian Wolf 3e28e5860d Fix JSON formatting in package.json 2026-04-05 22:06:06 +02:00
Julian Wolf 925a7e532d Add author information to package.json 2026-04-05 22:04:16 +02:00
Sid f3d761b28d Merge pull request #324 from JasonOA888/fix/306-persist-user-settings
fix: persist user settings across sessions
2026-04-05 12:55:31 -07:00
Julian Wolf c9861dbef8 Refactor CI workflow for E2E tests
Updated CI workflow to include E2E tests conditionally.
2026-04-05 21:54:03 +02:00
Julian Wolf 1591edbeca Add .deb files to build artifacts 2026-04-05 21:35:33 +02:00
Julian Wolf a3c2ed8ed1 Update Linux build command to include AppImage and deb 2026-04-05 21:34:26 +02:00
Siddharth ae971bc480 fix: resolve type error, formatting, and import order from PR #321 2026-04-05 11:03:45 -07:00
Sid 213637967e fix(editor): track unsaved changes for new projects (#321)
fix(editor): track unsaved changes for new projects
2026-04-05 11:02:42 -07:00
JasonOA888 a8427b950e fix: resolve lint errors for CI
- Add updateState to useEffect dependency array
- Remove ineffective biome-ignore suppression comment
- Fix formatting in userPreferences.ts per biome rules
2026-04-06 02:01:01 +08:00
Siddharth c868469be5 fix: auto-finalize duration bug, restore cancelRecording, and add i18n for pause tooltips 2026-04-05 10:17:35 -07:00
Sid e90bba82ef feat: add pause/resume recording (#314)
added a new Feature that allows user to pause/resume while screen rec…
2026-04-05 10:05:38 -07:00
Siddharth 475cbcd76c revert: undo manual merge of PR #314 2026-04-05 10:05:04 -07:00
Siddharth 08f66c7c25 feat: add pause/resume recording with duration fix 2026-04-05 09:57:35 -07:00
Ayush765-spec 013312be1f Refactor: update 'New Recording' dialog and atomize confirm workflow (plus lint fixes) 2026-04-05 22:27:32 +05:30
Ayush Mukherjee 735dd2a191 Merge branch 'siddharthvaddem:main' into main 2026-04-05 22:14:49 +05:30
Siddharth 7072c05edd fix: duration bug in auto-finalize path and add i18n for pause tooltip 2026-04-05 09:39:28 -07:00
Manish 0bc3bbca6b Merge branch 'main' into feature/pause-button 2026-04-05 22:03:35 +05:30
Sid 5340272530 Merge pull request #313 from theaiagent/feature/frame-step-navigation
feat: add arrow key frame-by-frame playhead navigation
2026-04-05 08:49:43 -07:00
Garry Laly 2ee7ccd89c fix: feedback coderabbit 2026-04-05 20:19:31 +07:00
Garry Laly 79201569c5 feat: Add webcam size presets with slider 2026-04-05 20:00:44 +07:00
Garry Laly ca962ff16b feat: Add webcam size presets (small/medium/large) 2026-04-05 19:45:50 +07:00
Sid da16872809 Merge pull request #295 from abres33/feature/cancel-recording
feat: add Cancel Recording button to HUD
2026-04-04 22:10:17 -07:00
Sid 11788ad703 Merge pull request #332 from marcgabe15/addDiscord
Add discord
2026-04-04 20:35:23 -07:00
Matthew Hrehirchuk 2712d8a41b fix: use view-aware byte extraction for BufferSource inputs 2026-04-04 21:00:16 -06:00
Matthew Hrehirchuk 21361d9bf8 fix: handle av1 VideoDecoder errors 2026-04-04 20:33:39 -06:00
Marc Diaz 66f9172a35 feat: add discord to readme 2026-04-04 20:52:14 -04:00
Marc Diaz bd604cb658 add discord to readme 2026-04-04 20:43:41 -04:00
Max Bailey 3b5ad5064e fix: resolve green MP4 exports on CachyOS/Arch Linux (Wayland)
On Linux/Wayland the implicit GPU-to-2D texture-sharing path used by
drawImage(webglCanvas) fails silently (EGL/Ozone), producing green
frames. Use explicit gl.readPixels to copy from GPU to CPU memory,
bypassing that path.
2026-04-04 19:12:15 -05:00
JasonOA888 4f48ecd4bc fix: address code review feedback for settings persistence
- Replace useRef with useState for prefsHydrated to prevent race condition
- Wrap localStorage.getItem in try/catch in loadUserPreferences
- Validate aspectRatio against known valid values
- Include 'good' in exportQuality validation, 'mp4' in exportFormat validation
2026-04-04 23:58:25 +08:00
JasonOA888 7d746196d2 fix: persist user settings across sessions (closes #306)
Load saved preferences (padding, aspect ratio, export quality, export format)
on mount and auto-save whenever these settings change. Uses the existing
userPreferences.ts utility with a ref guard to prevent overwriting saved prefs
with defaults before the initial load completes.
2026-04-04 23:27:56 +08:00
JasonOA888 d5f59a7b8e fix: persist user settings across sessions
Add userPreferences module to save/load padding, aspect ratio,
export format and quality to localStorage. Applied on mount
in VideoEditor.

Closes #306
2026-04-04 23:16:39 +08:00
cocoon 478fe316dc fix(editor): track unsaved changes for new projects 2026-04-04 13:23:51 +00:00
Ayush765-spec b451bdc03d Merge branch 'main' of https://github.com/Ayush765-spec/openscreen 2026-04-04 11:51:49 +05:30
Ayush765-spec 43ec6ee9cd fix(editor): localize new recording dialog and fix session clear behavior 2026-04-04 11:51:05 +05:30
Ayush Mukherjee 98da431da0 Merge branch 'siddharthvaddem:main' into main 2026-04-04 11:38:01 +05:30
Sid 21893f07af Merge pull request #288 from gulivan/feature/webcam-mask-shapes
Add webcam mask shape support
2026-04-03 22:56:01 -07:00
Sid 763c187f87 Merge pull request #281 from GuilhermeFaga/main
fix(#264): read raw pixels from canvas for VideoFrame to avoid silent failures on Linux
2026-04-03 22:50:15 -07:00
Sid 20567db245 Merge pull request #257 from xKeCo/feature/auto-follow-zoom
feat: add auto-follow zoom mode with cursor tracking
2026-04-03 22:42:02 -07:00
Sid 7a1113827c Merge pull request #318 from tmchow/feat/219-appimage-update-info
feat: embed AppImage update information for delta updates
2026-04-03 22:32:56 -07:00
Trevin Chow 7e298d3bbf feat: embed AppImage update information for delta updates
Add a top-level publish config in electron-builder.json5 pointing to
GitHub Releases. This embeds the update information URL in the AppImage
header, enabling tools like AppImageUpdate, AppImageLauncher, and
AppManager to perform delta updates instead of full re-downloads.

Also update the Linux build workflow to upload the generated .zsync file
alongside the .AppImage artifact.

Fixes #219
2026-04-03 20:14:20 -07:00
theaiagent 97c9a73578 fix: skip frame-step on ARIA widgets that own arrow keys
Expand the arrow key guard to also skip elements with
role="separator" (PanelResizeHandle), role="slider", and
role="spinbutton" so keyboard panel resizing is not intercepted.
2026-04-03 23:02:12 +03:00
theaiagent 3bfcd8576b fix: read live video.currentTime for rapid frame steps and add JSDoc
- Read currentTime directly from the video element instead of the React
  ref so rapid arrow key presses each advance by exactly one frame
- Add JSDoc docstrings to frameStep.ts exports
2026-04-03 22:44:25 +03:00
maniesh6900 b002f2a485 added a new Feature that allows user to pause/resume while screen recording, 2026-04-04 00:56:14 +05:30
theaiagent cd0f2ab318 fix: expand arrow key guard for form controls and wire i18n for fixed shortcuts
- Add HTMLSelectElement and contentEditable to the arrow key input guard
  to prevent intercepting native keyboard behavior on form controls
- Add i18nKey field to FixedShortcut interface and wire up i18n lookups
  in ShortcutsConfigDialog and KeyboardShortcutsHelp so fixed shortcut
  labels are properly localized
2026-04-03 22:06:45 +03:00
xKeCo 54df597160 ✨ feat: enhance adaptive smoothing for auto-follow zoom in video playback 2026-04-03 12:26:07 -05:00
theaiagent e5430eed39 feat: add arrow key frame-by-frame playhead navigation (#302) 2026-04-03 17:50:53 +03:00
theaiagent baa30a9d6a test: add unit tests for frame step time computation 2026-04-03 17:44:26 +03:00
theaiagent b709d0d240 feat: add frame step entries to FIXED_SHORTCUTS display list 2026-04-03 17:37:13 +03:00
theaiagent 11bad60eb2 feat: add i18n labels for frame step shortcuts (en, es, zh-CN) 2026-04-03 17:36:43 +03:00
Ayush Mukherjee 5259ae5d87 Merge branch 'siddharthvaddem:main' into main 2026-04-03 18:58:00 +05:30
Ayush765-spec 14cd045e65 [Feature]: Ability to start a new recording from the editor 2026-04-03 18:57:05 +05:30
Adam 27853cc2c3 fix: await setCurrentVideoPath and narrow catch in gif-export E2E test 2026-04-03 02:32:47 -05:00
Adam d6933813bd fix: move try/catch outside evaluate() in gif-export E2E test 2026-04-03 02:25:29 -05:00
Adam 2b471783c0 feat: add Cancel Recording button to HUD 2026-04-03 02:00:36 -05:00
Sid b101820ab8 Merge pull request #293 from abres33/fix/restart-recording-windows
fix: prevent double-finalize race condition in restartRecording on Windos
2026-04-02 23:35:46 -07:00
Sid 3061c141c6 Merge pull request #249 from EtienneLescot/feat/webcam-selector-optimization
feat: added webcam source selector and optimized horizontal UI
2026-04-02 23:30:30 -07:00
Adam 846cf71e09 fix: prevent double-finalize race condition in restartRecording on Windows 2026-04-03 01:12:26 -05:00
Ishan Panta 3895ca985f [add] extend speed options with higher presets and custom speed input
add 3x, 4x, 5x speed presets and a custom playback speed input field
that accepts any integer value up to 16x. change PlaybackSpeed type
from a fixed union to number with min/max constants and clamp utility.
update project persistence to validate any speed in range instead of
exact value matching. add i18n keys for en, es, zh-CN.

closes #252
2026-04-03 08:37:16 +05:45
Ivan 9d0ccf3bde Add webcam mask shape support 2026-04-03 00:09:51 +03:00
Faga 914a3c7f7b fix: read raw pixels from canvas for VideoFrame to avoid silent failures on Linux 2026-04-02 11:55:21 -03:00
Siddharth 2f36160174 version bump 2026-04-01 22:08:43 -07:00
xKeCo 05a87a8ab1 Revert "demo: add example project file for auto-follow zoom"
This reverts commit 5c6621293a.
2026-04-01 02:53:03 -05:00
xKeCoandClaude Sonnet 4.6 5c6621293a demo: add example project file for auto-follow zoom
Contains the zoom region configuration used in the PR demo video:
two auto-follow zoom regions and one manual zoom region.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 02:51:25 -05:00
xKeCo 163b12d6fc ♻️ refactor: refactor zoom focus handling in video editor settings and playback 2026-04-01 02:38:42 -05:00
xKeCo 3be195cc15 ✨ feat: smooth auto-follow zoom with export parity 2026-04-01 01:41:20 -05:00
Etienne Lescot baec9a7585 fix: focusable element when webcam expanded with no devices, add error test
- LaunchWindow: render sr-only <select> when webcamExpanded but
  cameraDevices.length === 0 (loading/error/empty), so keyboard users
  always have a focusable element even in no-camera states
- useCameraDevices.test: add error-branch test asserting error message,
  empty devices array and isLoading=false when enumerateDevices rejects
2026-03-27 16:28:53 +01:00
Etienne Lescot 9817c85acf fix: address coderabbit review (concurrent stream, collapsed label, unified select, test quality)
- useCameraDevices: remove getUserMedia label probe to avoid conflict with
  useScreenRecorder acquiring the real stream; use enumerateDevices only and
  fall back to 'Camera <id>' for unlabeled devices; gate effect on enabled flag
- LaunchWindow: fix selectedCameraLabel to reflect loading/error/empty states
  in the collapsed view (was always showing 'Default Camera')
- LaunchWindow: unify webcam <select> to a single always-mounted element
  (sr-only when unavailable); mirrors the mic selector pattern
- useCameraDevices.test.ts: re-seed mockGetUserMedia in beforeEach after
  vi.resetAllMocks(); update permission test to assert fallback label behavior
2026-03-27 15:15:43 +01:00
Etienne Lescot 9762448929 fix: address coderabbit comments (loading state + keyboard access)
- LaunchWindow: expose isLoading/error from useCameraDevices; show
  'Searching...' only while enumeration is in flight, 'Camera unavailable'
  on error, 'No camera found' when list is empty (fixes perpetual loading state)
- LaunchWindow: keep <select> always mounted (sr-only when collapsed) and
  expand panel on focus as well as hover; fixes keyboard inaccessibility for
  both mic and webcam selectors
- i18n: add webcam.noneFound and webcam.unavailable to en/es/zh-CN locales
2026-03-27 14:53:41 +01:00
Etienne Lescot eade28079d fix: address PR review comments
- useCameraDevices: remove selectedDeviceId from useEffect deps (use ref instead)
- useCameraDevices: fall back to first available device when selected device is unplugged
- i18n: add missing keys (audio.defaultMicrophone, webcam.defaultCamera, webcam.searching) to en/es/zh-CN
- LaunchWindow: replace hardcoded strings with t() i18n calls
- tests: add afterEach(vi.resetAllMocks()), improve permission test assertions, add stale device fallback test
2026-03-27 14:39:19 +01:00
Etienne Lescot fed5a44b5a fix: enforce identical badge height with h-[36px] on both selectors 2026-03-27 14:25:28 +01:00
Etienne Lescot a9222c9484 fix: equalize badge heights and reduce window to 160px 2026-03-27 14:15:46 +01:00
Etienne Lescot e72851d4ef fix: use fixed positioning for HUD and device selectors to avoid h-full clipping 2026-03-27 14:09:54 +01:00
Etienne Lescot 317089d57f fix: restore flex layout to ensure HUD renders in transparent Electron window 2026-03-27 14:04:04 +01:00
Etienne Lescot 0a5e57ce76 feat: add webcam source selector with stable HUD layout
- Add useCameraDevices hook to enumerate video input devices
- Update useScreenRecorder to support webcamDeviceId selection
- Add device selector UI above HUD bar (mic + webcam, hover-to-expand)
- All selectors and HUD bar are absolute-positioned to prevent layout shifts
- Increase HUD window to 600x200px to accommodate device panels
- Add unit tests for useCameraDevices hook
2026-03-27 13:45:52 +01:00
Sid e35780bd85 Merge pull request #244 from ateendra24/fix-issue-226
feat: add fullscreen video player
2026-03-22 09:39:54 -07:00
AP Solanki eae3f119a4 feat: Implement PlaybackControls component and add i18n files for common terms in English, Spanish, and Chinese. 2026-03-22 15:33:43 +05:30
AP Solanki 5d561ff06f feat: add fullscreen video player 2026-03-22 14:17:44 +05:30
Siddharth a8bb0e88d5 improved vertical split gated behind 9:16 2026-03-21 23:15:46 -07:00
Siddharth cbbe2d7fbf movable camera pip 2026-03-21 22:04:10 -07:00
Siddharth 7aca8b8bc1 move project settings to top 2026-03-21 20:07:09 -07:00
Siddharth 4a299063c3 lang support 2026-03-21 18:18:43 -07:00
Siddharth 3d680e8521 Merge feat(export): allow re-saving exported video on dialog cancel (PR #181) 2026-03-21 17:06:36 -07:00
Siddharth c322825969 feat(export): allow re-saving exported video on dialog cancel 2026-03-21 17:06:25 -07:00
Sid ece93683b8 Merge pull request #243 from ryujh030820/feature/improve-timeline-navigation
Improve timeline navigation while scrubbing and scrolling
2026-03-21 16:43:25 -07:00
JH 203282be43 fix: pan timeline on row scroll 2026-03-20 16:52:16 +09:00
JH d8871d9228 fix: pan timeline when dragging playhead to edges 2026-03-20 16:25:19 +09:00
Sid dd0b7d6586 Merge pull request #210 from linyqh/codex/exporter-timeout-fallback
Stabilize video export on Windows
2026-03-19 20:53:32 -07:00
linyq 459b71f792 fix: satisfy biome formatting in video exporter 2026-03-20 10:19:49 +08:00
Sid adc0cf795c Merge pull request #237 from ryujh030820/fix/gradient-export-rendering
fix: fix gradient background export rendering
2026-03-19 18:27:43 -07:00
JH 796506819d Merge branch 'main' into fix/gradient-export-rendering 2026-03-20 09:32:55 +09:00
linyqh 2a2d7e7aba Stabilize video export on Windows 2026-03-20 00:04:34 +08:00
Sid 3eeecc46cf Merge pull request #241 from marcusschiesser/codex/add-multiple-layout-presets-for-video
Add selectable webcam layout presets (Picture in Picture, Vertical Stack)
2026-03-19 08:28:20 -07:00
Marcus Schiesser 6236d2a13d fix: handle export and camera access edge cases 2026-03-19 20:03:55 +08:00
Marcus Schiesser c84c244761 Pin Node and npm versions 2026-03-19 19:25:07 +08:00
Marcus Schiesser 83a60926d8 fix: center stacked screen and webcam layout 2026-03-19 17:51:51 +08:00
Marcus Schiesser 579887e2f8 fix: improve camera permission handling 2026-03-19 16:49:46 +08:00
Marcus Schiesser a0682e6716 feat: add selectable webcam layout presets 2026-03-19 13:05:42 +08:00
JH 038d6c40ab fix: fix gradient background export rendering 2026-03-18 14:30:21 +09:00
Sid 45636410fe Merge pull request #234 from siddharthvaddem/codex/issue-231
fix 231
2026-03-17 20:35:50 -07:00
Siddharth 69f1b4d20f fix 231 2026-03-17 20:25:34 -07:00
Sid d968689975 Merge pull request #233 from siddharthvaddem/codex/issue-230
fix: avoid false early decode failures
2026-03-17 20:10:40 -07:00
Siddharth 7e65d52847 fix 2026-03-17 20:07:15 -07:00
Siddharth 1680ef9b77 fix: guard exported file paths in export flow 2026-03-17 19:46:56 -07:00
Siddharth b7070f3ac8 Merge remote-tracking branch 'origin/main' into codex/issue-230 2026-03-17 19:45:27 -07:00
Siddharth de18a2f46f fix: avoid false early decode failures 2026-03-17 19:30:47 -07:00
Sid 7a6efc5df9 Merge pull request #232 from siddharthvaddem/codex/saved-to-location
bring back show folder
2026-03-17 19:06:53 -07:00
Siddharth 4b8c95f04f bring back show folder 2026-03-17 19:05:59 -07:00
Siddharth 0f123283b3 Merge remote-tracking branch 'origin/main' into main 2026-03-17 18:55:46 -07:00
Siddharth b33ec5e2d7 fix: restore webcam sessions and stop export deadlocks 2026-03-17 18:50:05 -07:00
Siddharth 0a0dd088c3 Merge branch 'codex/pr-229' into main 2026-03-17 18:47:19 -07:00
Sid 2669b380a3 Merge pull request #216 from prayaslashkari/feature/restart-recording
feat: Add Restart Recording Functionality
2026-03-17 16:22:30 -07:00
Sid 0935dac70a Merge pull request #228 from prayaslashkari/feature/resizeable-video-editor
refactor: Resizable Video Editor Layout, Migrated inline styles to TailwindCSS
2026-03-17 15:37:56 -07:00
Prayas Lashkari e2147bec63 feat: enhance restart recording functionality to prevent concurrent restarts 2026-03-17 13:48:31 -04:00
Marcus Schiesser 3d2d0a4dbc fix: always release exporter video frames 2026-03-17 20:35:21 +08:00
Marcus Schiesser 1591f7dfcb fix: restore passing checks for webcam overlay changes 2026-03-17 20:29:13 +08:00
Marcus Schiesser c3e4c86b33 fix: reset webcam state on access denial 2026-03-17 20:07:10 +08:00
Marcus Schiesser 942a7e599a fix: allow webcam toggle while recording 2026-03-17 20:05:37 +08:00
Marcus Schiesser 776ed954f2 fix: always tear down webcam export queues 2026-03-17 20:03:14 +08:00
Marcus Schiesser f1a453b9b2 fix: finalize externally stopped recordings 2026-03-17 19:57:45 +08:00
Marcus Schiesser e4263d4597 fix: sync webcam preview playback speed 2026-03-17 19:37:12 +08:00
Marcus Schiesser 2fb5b3b574 Add webcam recording overlay support 2026-03-17 19:09:34 +08:00
Prayas Lashkari 9a5d94a1c8 refactor: update VideoEditor layout and add config.json for setup and teardown 2026-03-17 02:12:44 -04:00
Prayas Lashkari 119c3acb18 feat: implement async restart recording functionality to ensure proper session handling 2026-03-17 01:57:55 -04:00
Sid 881acdb26f Merge pull request #225 from elevchyt/notification-area-hud-open
notification area hud open fix with small window open refactor
2026-03-16 21:03:37 -07:00
Sid 4a308fde12 Merge pull request #223 from marcgabe15/marcdiaz/e2e
E2E Testing with Playwright
2026-03-16 20:59:04 -07:00
Sid fc8a4db8f1 Merge pull request #222 from EtienneLescot/fix/export-local-file-loading
fix: read local export sources through electron IPC
2026-03-16 20:56:15 -07:00
Marc Diaz ac4f82484b revert change 2026-03-16 13:46:57 -04:00
Marc Diaz e9f0fda397 fix: possible race condition on test 2026-03-16 13:27:44 -04:00
elevchyt 4655e71ca5 notification area hud open fix with small window open refactor 2026-03-16 19:25:12 +02:00
Marc Diaz e82332647a fix: remove ffmpeg 2026-03-16 11:31:05 -04:00
Marc Diaz 9fb91dd17b Merge pull request #1 from marcgabe15/marcdiaz/test
feat(test): add an e2e test
2026-03-16 11:28:13 -04:00
Sid c8cf052fc9 Merge pull request #221 from EtienneLescot/feat/motion-blur-slider
feat: replace motion blur toggle with intensity slider
2026-03-16 08:27:38 -07:00
Marc Diaz 61d89831bb fix: add xvfb run 2026-03-16 11:24:30 -04:00
Marc Diaz 9f6ef0f582 feat(test): add an e2e test 2026-03-16 11:17:26 -04:00
Etienne Lescot ea68300634 fix: read local export sources via electron ipc 2026-03-16 13:01:32 +01:00
Etienne Lescot 446e3a35fc fix: avoid history checkpoint spam on motion blur drag 2026-03-16 12:51:54 +01:00
Etienne Lescot c35a33203b fix: increase motion blur intensity range 2026-03-16 12:40:08 +01:00
Etienne Lescot dd84edaf41 feat: replace motion blur toggle with intensity slider
Motion blur was a boolean switch (on/off). This changes it to a slider
from 0 (off) to 1 (full intensity), with 0.35 as the recommended sweet
spot per feedback on PR #207.

- EditorState/ProjectEditorState: motionBlurEnabled:bool → motionBlurAmount:number
- SettingsPanel: Switch → Slider (0–1, step 0.01); shows 'off' or value
- VideoPlayback/zoomTransform: scale blur by amount instead of boolean gate
- FrameRenderer/VideoExporter/GifExporter: propagate numeric amount
- projectPersistence: backward-compat loader (old true → 0.35, false → 0)
2026-03-16 12:22:16 +01:00
Sid 9d71f509b8 Merge pull request #207 from EtienneLescot/feat/recordly-cursor-pipeline
feat: rework zoom transitions and motion blur
2026-03-15 19:30:45 -07:00
Siddharth 9687157aba Merge main into PR #186 and resolve SourceSelector conflict 2026-03-15 18:27:29 -07:00
Siddharth e2075f15e9 Merge main into PR #185 and resolve native aspect conflicts 2026-03-15 17:13:53 -07:00
Siddharth d182854270 Merge PR #184: resolve crop control conflicts 2026-03-15 16:52:46 -07:00
Etienne Lescot 7a8d0f449a feat: narrow PR to zoom transitions and motion blur 2026-03-15 10:29:23 +01:00
Prayas Lashkari 0727b61de7 feat: add restart recording functionality in LaunchWindow and useScreenRecorder 2026-03-15 02:07:39 -04:00
Siddharth 56988e86e2 custom install loc 2026-03-14 15:57:22 -07:00
Sid 965d3e5f4c Merge pull request #211 from prayaslashkari/bug/crop-window
fix: Fix crop window behavior
2026-03-14 12:48:10 -07:00
Siddharth 5f6576768c normalize paths on all OS 2026-03-14 12:43:12 -07:00
Prayas Lashkari 6c086be1b6 fix: rename crop dropdown state to crop modal for clarity 2026-03-14 15:32:59 -04:00
Prayas Lashkari b52d27bf56 fix: add peer dependencies to package-lock.json 2026-03-14 15:26:27 -04:00
Prayas Lashkari e3c922d032 feat: add crop functionality with snapshot handling in SettingsPanel 2026-03-14 15:25:51 -04:00
Siddharth 16dea49fa8 fix audio desync and speed issue 2026-03-14 11:58:43 -07:00
Sid 575a339550 Merge pull request #206 from EtienneLescot/fix/windows-export-stall
Fix Windows export finalization stalls
2026-03-14 11:15:26 -07:00
Sid e5fa783b59 Merge pull request #205 from EtienneLescot/fix/issue-197-windows-paths
Fix Windows cursor telemetry path resolution
2026-03-14 09:46:51 -07:00
Etienne Lescot b5cc7777d7 Fix export finalization stalls on Windows 2026-03-14 11:57:59 +01:00
Etienne Lescot e72fb8252c Fix Windows cursor telemetry path resolution 2026-03-14 11:22:45 +01:00
Siddharth 5e8bb99e96 fix playback callback to not be in pixi setup dependency 2026-03-13 23:00:11 -07:00
Siddharth 1b08618831 project save/ close fix 2026-03-13 19:37:00 -07:00
Sid 144e34318e Merge pull request #204 from marcgabe15/feature/increase-worker-count
feat(gif-worker): increase amount of web workers based on hardwarecon…
2026-03-13 19:18:58 -07:00
Sid 6035719252 Merge pull request #202 from prayaslashkari/refactor/launch-window-ux
feat: UX Improvements in Launch Window
2026-03-13 18:50:03 -07:00
Sid 2af33894e9 Merge pull request #174 from FabLrc/feature/undo-redo
feat: implement undo/redo functionality in video editor
2026-03-13 18:49:27 -07:00
Marc Diaz 63fd87612e feat(gif-worker): increase amount of web workers based on hardwareconcurrecy 2026-03-13 17:32:20 -04:00
FabLrc 4b79909116 fix: stabilize lint/typecheck and shortcut typing 2026-03-13 11:24:54 +01:00
FabLrc 0a6895e89f Merge origin/main into feature/undo-redo 2026-03-13 10:55:40 +01:00
Prayas Lashkari 36a0a304d5 refactor: clean up imports and streamline JSX formatting in LaunchWindow component 2026-03-13 00:17:08 -04:00
Siddharth 4f68df1db8 fix exporter 2026-03-12 21:16:20 -07:00
Prayas Lashkari 7422e16b1e refactor: update package-lock.json to version 1.2.0 and add @radix-ui/react-tooltip dependency 2026-03-12 22:47:28 -04:00
Prayas Lashkari 151a3b2902 refactor: integrate Tooltip component and enhance LaunchWindow with tooltips 2026-03-12 22:14:44 -04:00
Prayas Lashkari 066832a3bd refactor: enhance LaunchWindow styles and structure for improved UX 2026-03-12 18:43:36 -04:00
Prayas Lashkari 118158b8ee refactor: add new animations and boxShadow styles for mic panel and recording effects 2026-03-12 18:43:24 -04:00
Prayas Lashkari 948e2b1e4a refactor: added timeUtils 2026-03-12 18:43:10 -04:00
Prayas Lashkari c48243360b refactor: improve icon handling and formatting in LaunchWindow component 2026-03-12 17:15:18 -04:00
Siddharth 7833dee014 fix microphone permission in build 2026-03-08 14:07:42 -07:00
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
HemkeshandClaude Opus 4.6 dcf35a6ede Default to Windows tab when no screens available and show source counts
On Linux (e.g. Ubuntu), screen sources are often empty. This defaults
the source selector to the Windows tab when there are no screens, and
shows the count of each source type in the tab labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:51:09 -06:00
HemkeshandClaude Opus 4.6 c8ebef026b Add "Native" aspect ratio option to export at cropped video dimensions
Adds a "Native" option to the aspect ratio dropdown that uses the cropped
video's actual aspect ratio, so the video fills the entire frame with no
background visible. Selecting Native also sets padding to 0 automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:48:47 -06:00
HemkeshandClaude Opus 4.6 7226632fc4 Add precise crop controls with numeric inputs, aspect ratio presets, and drag-to-move
- Add X, Y, W, H pixel input fields in the crop modal for exact positioning
- Add aspect ratio preset dropdown (16:9, 9:16, 4:3, 3:4, 1:1, 21:9, Free)
- Add lock/unlock button to maintain aspect ratio when resizing
- Display source video resolution for reference
- Add drag-to-move: click inside the crop area to pan it around
- Fix dropdown styling for dark mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:37:17 -06:00
SaiVaraprasad Medapati 60d3dfaef5 Merge branch 'main' into feature/reveal-export-folder 2026-03-04 19:33:39 +05:30
FabLrc 2ccead5fb9 Merge remote-tracking branch 'origin/main' into feature/undo-redo
# Conflicts:
#	src/components/video-editor/VideoEditor.tsx
2026-03-04 12:41:49 +01:00
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
FabLrc 6d44dafd96 fix: Fixing speed undoable and add undo/redo to the list of shortucts configuration 2026-03-02 16:26:42 +01:00
FabLrc e6e3abb88c Merge branch 'main' into feature/undo-redo
# Conflicts:
#	src/components/video-editor/KeyboardShortcutsHelp.tsx
#	src/components/video-editor/VideoEditor.tsx
#	src/components/video-editor/timeline/TimelineEditor.tsx
2026-03-02 15:45:03 +01: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
FabLrc 0e85679b14 feat: implement undo/redo functionality in video editor 2026-03-01 12:47:52 +01: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
222 changed files with 42836 additions and 16850 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 },
],
},
}
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: siddharthvaddem
patreon: # Replace with a single Patreon username
open_collective: # Replace with a sissgle Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-38
View File
@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+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
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,48 @@
name: Feature Request
description: Suggest an idea for this project
title: "[Feature]: "
labels: ["enhancement", "feature-request"]
body:
- type: checkboxes
attributes:
label: Search existing issues
description: Please search to see if an issue already exists for this feature request.
options:
- label: I have searched the existing issues
required: true
- type: textarea
id: problem-description
attributes:
label: Is your feature request related to a problem?
description: A clear and concise description of what the problem is.
placeholder: e.g., I'm always frustrated when I have to...
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
placeholder: Describe the feature or change you're proposing
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
placeholder: Have you considered any workarounds or alternative approaches?
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
placeholder: Links, mockups, or any additional information
validations:
required: false
+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!*
+101
View File
@@ -0,0 +1,101 @@
name: Build Electron App
on:
workflow_dispatch:
jobs:
build-windows:
runs-on: windows-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 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
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
release/**/*.zsync
release/**/*.deb
retention-days: 30
+57
View File
@@ -0,0 +1,57 @@
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
test:
name: Test
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 test:browser:install
- run: npm run test:browser
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
+9 -1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.local
@@ -23,5 +24,12 @@ dist-ssr
*.sln
*.sw?
release/**
*.kiro/
# npx electron-builder --mac --win
# npx electron-builder --mac --win
# Playwright
test-results
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+1
View File
@@ -0,0 +1 @@
22.22.1
+102 -67
View File
@@ -1,67 +1,102 @@
<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, gradients or your own picture for your background
- Motion blur and exponential easing for smoother pan and zoom effects
## 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>
&nbsp;
<a href="https://discord.gg/yAQQhRaEeg">
<img src="https://img.shields.io/discord/pHAUbcqNd?logo=discord&label=Discord&color=5865F2" alt="Join Discord" />
</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: 0.2467; margin-right: 12px;" />
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 0.1678; margin-right: 12px;" />
</p>
## Core Features
- Record specific windows or your whole screen.
- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position.
- Record microphone and system audio.
- 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 the speed of 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 works).
- **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 work).
## 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": {}
}
-343
View File
@@ -1,343 +0,0 @@
import { BrowserWindow as P, screen as O, ipcMain as c, desktopCapturer as M, shell as W, app as p, nativeImage as L, Tray as V, Menu as U } from "electron";
import { fileURLToPath as E } from "node:url";
import t from "node:path";
import m from "node:fs/promises";
import { uIOhook as w } from "uiohook-napi";
const S = t.dirname(E(import.meta.url)), A = t.join(S, ".."), y = process.env.VITE_DEV_SERVER_URL, x = t.join(A, "dist");
function C() {
const e = new P({
width: 250,
height: 80,
minWidth: 250,
maxWidth: 250,
minHeight: 80,
maxHeight: 80,
frame: !1,
transparent: !0,
resizable: !1,
alwaysOnTop: !0,
skipTaskbar: !0,
hasShadow: !1,
webPreferences: {
preload: t.join(S, "preload.mjs"),
nodeIntegration: !1,
contextIsolation: !0,
backgroundThrottling: !1
}
});
return e.webContents.on("did-finish-load", () => {
e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
}), y ? e.loadURL(y + "?windowType=hud-overlay") : e.loadFile(t.join(x, "index.html"), {
query: { windowType: "hud-overlay" }
}), e;
}
function N() {
const e = new P({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
transparent: !1,
resizable: !0,
alwaysOnTop: !1,
skipTaskbar: !1,
title: "OpenScreen",
backgroundColor: "#000000",
webPreferences: {
preload: t.join(S, "preload.mjs"),
nodeIntegration: !1,
contextIsolation: !0,
webSecurity: !1
}
});
return e.maximize(), e.webContents.on("did-finish-load", () => {
e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
}), y ? e.loadURL(y + "?windowType=editor") : e.loadFile(t.join(x, "index.html"), {
query: { windowType: "editor" }
}), e;
}
function H() {
const { width: e, height: n } = O.getPrimaryDisplay().workAreaSize, i = new P({
width: 620,
height: 420,
minHeight: 350,
maxHeight: 500,
x: Math.round((e - 620) / 2),
y: Math.round((n - 420) / 2),
frame: !1,
resizable: !1,
alwaysOnTop: !0,
transparent: !0,
backgroundColor: "#00000000",
webPreferences: {
preload: t.join(S, "preload.mjs"),
nodeIntegration: !1,
contextIsolation: !0
}
});
return y ? i.loadURL(y + "?windowType=source-selector") : i.loadFile(t.join(x, "index.html"), {
query: { windowType: "source-selector" }
}), i;
}
let u = !1, _ = !1, d = 0, f = [];
function z() {
if (u)
return { success: !1, message: "Already tracking" };
if (u = !0, d = performance.now(), f = [], _)
return { success: !0, message: "Mouse tracking resumed", startTime: d };
q();
try {
return w.start(), _ = !0, { success: !0, message: "Mouse tracking started", startTime: d };
} catch (e) {
return console.error("Failed to start mouse tracking:", e), u = !1, { success: !1, message: "Failed to start hook", error: e };
}
}
function B() {
if (!u)
return { success: !1, message: "Not currently tracking" };
u = !1;
const e = performance.now() - d;
return {
success: !0,
message: "Mouse tracking stopped",
data: {
startTime: d,
events: f,
duration: e
}
};
}
function q() {
w.on("mousemove", (e) => {
if (u) {
const i = {
type: "move",
timestamp: performance.now() - d,
x: e.x,
y: e.y
};
f.push(i);
}
}), w.on("mousedown", (e) => {
if (u) {
const i = {
type: "down",
timestamp: performance.now() - d,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
};
f.push(i);
}
}), w.on("mouseup", (e) => {
if (u) {
const i = {
type: "up",
timestamp: performance.now() - d,
x: e.x,
y: e.y,
button: e.button
};
f.push(i);
}
}), w.on("click", (e) => {
if (u) {
const i = {
type: "click",
timestamp: performance.now() - d,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
};
f.push(i);
}
});
}
function $() {
return [...f];
}
function G() {
if (_)
try {
w.stop(), _ = !1, u = !1, f = [];
} catch (e) {
console.error("Error cleaning up mouse tracking:", e);
}
}
let b = null;
function J(e, n, i, v, T) {
c.handle("get-sources", async (o, a) => (await M.getSources(a)).map((r) => ({
id: r.id,
name: r.name,
display_id: r.display_id,
thumbnail: r.thumbnail ? r.thumbnail.toDataURL() : null,
appIcon: r.appIcon ? r.appIcon.toDataURL() : null
}))), c.handle("select-source", (o, a) => {
b = a;
const s = v();
return s && s.close(), b;
}), c.handle("get-selected-source", () => b), c.handle("open-source-selector", () => {
const o = v();
if (o) {
o.focus();
return;
}
n();
}), c.handle("switch-to-editor", () => {
const o = i();
o && o.close(), e();
}), c.handle("start-mouse-tracking", () => z()), c.handle("stop-mouse-tracking", () => B()), c.handle("store-recorded-video", async (o, a, s) => {
try {
const r = t.join(h, s);
return await m.writeFile(r, Buffer.from(a)), {
success: !0,
path: r,
message: "Video stored successfully"
};
} catch (r) {
return console.error("Failed to store video:", r), {
success: !1,
message: "Failed to store video",
error: String(r)
};
}
}), c.handle("store-mouse-tracking-data", async (o, a) => {
try {
const s = $();
if (s.length === 0)
return { success: !1, message: "No tracking data to save" };
const r = t.join(h, a);
return await m.writeFile(r, JSON.stringify(s, null, 2), "utf-8"), {
success: !0,
path: r,
eventCount: s.length,
message: "Mouse tracking data stored successfully"
};
} catch (s) {
return console.error("Failed to store mouse tracking data:", s), {
success: !1,
message: "Failed to store mouse tracking data",
error: String(s)
};
}
}), c.handle("get-recorded-video-path", async () => {
try {
const a = (await m.readdir(h)).filter((R) => R.endsWith(".webm"));
if (a.length === 0)
return { success: !1, message: "No recorded video found" };
const s = a.sort().reverse()[0];
return { success: !0, path: t.join(h, s) };
} catch (o) {
return console.error("Failed to get video path:", o), { success: !1, message: "Failed to get video path", error: String(o) };
}
}), c.handle("set-recording-state", (o, a) => {
T && T(a, (b || { name: "Screen" }).name);
}), c.handle("open-external-url", async (o, a) => {
try {
return await W.openExternal(a), { success: !0 };
} catch (s) {
return console.error("Failed to open URL:", s), { success: !1, error: String(s) };
}
}), c.handle("get-asset-base-path", () => {
try {
return p.isPackaged ? t.join(process.resourcesPath, "assets") : t.join(p.getAppPath(), "public", "assets");
} catch (o) {
return console.error("Failed to resolve asset base path:", o), null;
}
}), c.handle("save-exported-video", async (o, a, s) => {
try {
const r = p.getPath("downloads"), R = t.join(r, s);
return await m.writeFile(R, Buffer.from(a)), {
success: !0,
path: R,
message: "Video exported successfully"
};
} catch (r) {
return console.error("Failed to save exported video:", r), {
success: !1,
message: "Failed to save exported video",
error: String(r)
};
}
});
}
const K = t.dirname(E(import.meta.url)), h = t.join(p.getPath("userData"), "recordings");
async function Q() {
try {
const e = await m.readdir(h), n = Date.now(), i = 1 * 24 * 60 * 60 * 1e3;
for (const v of e) {
const T = t.join(h, v), o = await m.stat(T);
n - o.mtimeMs > i && (await m.unlink(T), console.log(`Deleted old recording: ${v}`));
}
} catch (e) {
console.error("Failed to cleanup old recordings:", e);
}
}
async function X() {
try {
await m.mkdir(h, { recursive: !0 }), console.log("Recordings directory ready:", h);
} catch (e) {
console.error("Failed to create recordings directory:", e);
}
}
process.env.APP_ROOT = t.join(K, "..");
const Y = process.env.VITE_DEV_SERVER_URL, ie = t.join(process.env.APP_ROOT, "dist-electron"), I = t.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = Y ? t.join(process.env.APP_ROOT, "public") : I;
let l = null, k = null, g = null, j = "";
function D() {
l = C();
}
function Z() {
const e = t.join(process.env.VITE_PUBLIC || I, "rec-button.png");
let n = L.createFromPath(e);
n = n.resize({ width: 24, height: 24, quality: "best" }), g = new V(n), F();
}
function F() {
if (!g) return;
const e = [
{
label: "Stop Recording",
click: () => {
l && !l.isDestroyed() && l.webContents.send("stop-recording-from-tray");
}
}
], n = U.buildFromTemplate(e);
g.setContextMenu(n), g.setToolTip(`Recording: ${j}`);
}
function ee() {
l && (l.close(), l = null), l = N();
}
function te() {
return k = H(), k.on("closed", () => {
k = null;
}), k;
}
p.on("window-all-closed", () => {
});
p.on("activate", () => {
P.getAllWindows().length === 0 && D();
});
p.on("before-quit", async (e) => {
e.preventDefault(), G(), await Q(), p.exit(0);
});
p.whenReady().then(async () => {
await X(), J(
ee,
te,
() => l,
() => k,
(e, n) => {
j = n, e ? (g || Z(), F(), l && l.minimize()) : (g && (g.destroy(), g = null), l && l.restore());
}
), D();
});
export {
ie as MAIN_DIST,
h as RECORDINGS_DIR,
I as RENDERER_DIST,
Y as VITE_DEV_SERVER_URL
};
-1
View File
@@ -1 +0,0 @@
"use strict";const e=require("electron");e.contextBridge.exposeInMainWorld("electronAPI",{getAssetBasePath:async()=>await e.ipcRenderer.invoke("get-asset-base-path"),getSources:async r=>await e.ipcRenderer.invoke("get-sources",r),switchToEditor:()=>e.ipcRenderer.invoke("switch-to-editor"),openSourceSelector:()=>e.ipcRenderer.invoke("open-source-selector"),selectSource:r=>e.ipcRenderer.invoke("select-source",r),getSelectedSource:()=>e.ipcRenderer.invoke("get-selected-source"),startMouseTracking:()=>e.ipcRenderer.invoke("start-mouse-tracking"),stopMouseTracking:()=>e.ipcRenderer.invoke("stop-mouse-tracking"),storeRecordedVideo:(r,t)=>e.ipcRenderer.invoke("store-recorded-video",r,t),storeMouseTrackingData:r=>e.ipcRenderer.invoke("store-mouse-tracking-data",r),getRecordedVideoPath:()=>e.ipcRenderer.invoke("get-recorded-video-path"),setRecordingState:r=>e.ipcRenderer.invoke("set-recording-state",r),onStopRecordingFromTray:r=>{const t=()=>r();return e.ipcRenderer.on("stop-recording-from-tray",t),()=>e.ipcRenderer.removeListener("stop-recording-from-tray",t)},openExternalUrl:r=>e.ipcRenderer.invoke("open-external-url",r),saveExportedVideo:(r,t)=>e.ipcRenderer.invoke("save-exported-video",r,t)});
+48 -16
View File
@@ -6,30 +6,62 @@
"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": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"mac": {
"target": [
"dmg"
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${version}-Installer.${ext}"
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"publish": [{"provider": "github"}],
"mac": {
"hardenedRuntime": false,
"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.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
},
"linux": {
"target": [
"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"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
+157 -50
View File
@@ -1,50 +1,157 @@
/// <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>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
requestCameraAccess: () => Promise<{
success: boolean;
granted: boolean;
status: string;
error?: string;
}>;
getAssetBasePath: () => Promise<string | null>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{
success: boolean;
path?: string;
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
error?: string;
}>;
storeRecordedSession: (
payload: import("../src/lib/recordingSession").StoreRecordedSessionInput,
) => Promise<{
success: boolean;
path?: string;
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
error?: string;
}>;
getRecordedVideoPath: () => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
message?: string;
error?: string;
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
session: import("../src/lib/recordingSession").RecordingSession | null,
) => Promise<{
success: boolean;
session?: import("../src/lib/recordingSession").RecordingSession;
}>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
getCurrentRecordingSession: () => Promise<{
success: boolean;
session?: import("../src/lib/recordingSession").RecordingSession;
}>;
readBinaryFile: (filePath: string) => Promise<{
success: boolean;
data?: ArrayBuffer;
path?: string;
message?: string;
error?: string;
}>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
loadProjectFile: () => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
loadCurrentProjectFile: () => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
getPlatform: () => Promise<string>;
revealInFolder: (
filePath: string,
) => Promise<{ success: boolean; error?: string; message?: string }>;
getShortcuts: () => Promise<Record<string, unknown> | null>;
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
setLocale: (locale: string) => Promise<void>;
};
}
interface ProcessedDesktopSource {
id: string;
name: string;
display_id: string;
thumbnail: string | null;
appIcon: string | null;
}
interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
}
+62
View File
@@ -0,0 +1,62 @@
// Lightweight i18n for the Electron main process.
// Imports the same JSON translation files used by the renderer.
import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
import commonFr from "../src/i18n/locales/fr/common.json";
import dialogsFr from "../src/i18n/locales/fr/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
type Locale = "en" | "zh-CN" | "es" | "fr";
type Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>;
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
en: { common: commonEn, dialogs: dialogsEn },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
es: { common: commonEs, dialogs: dialogsEs },
fr: { common: commonFr, dialogs: dialogsFr },
};
let currentLocale: Locale = "en";
export function setMainLocale(locale: string) {
if (locale === "en" || locale === "zh-CN" || locale === "es" || locale === "fr") {
currentLocale = locale;
}
}
export function getMainLocale(): Locale {
return currentLocale;
}
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
const keys = dotPath.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : undefined;
}
function interpolate(str: string, vars?: Record<string, string | number>): string {
if (!vars) return str;
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
}
export function mainT(
namespace: Namespace,
key: string,
vars?: Record<string, string | number>,
): string {
const value =
getMessageValue(messages[currentLocale]?.[namespace], key) ??
getMessageValue(messages.en?.[namespace], key);
if (value == null) return `${namespace}.${key}`;
return interpolate(value, vars);
}
+933 -162
View File
File diff suppressed because it is too large Load Diff
-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)
}
}
}
+358 -118
View File
@@ -1,43 +1,40 @@
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,
session,
systemPreferences,
Tray,
} from "electron";
import { mainT, setMainLocale } from "./i18n";
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 +46,355 @@ 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 showMainWindow() {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
return;
}
createWindow();
}
function isEditorWindow(window: BrowserWindow) {
return window.webContents.getURL().includes("windowType=editor");
}
function sendEditorMenuAction(
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
) {
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
createEditorWindowWrapper();
targetWindow = mainWindow;
if (!targetWindow || targetWindow.isDestroyed()) return;
targetWindow.webContents.once("did-finish-load", () => {
if (!targetWindow || targetWindow.isDestroyed()) return;
targetWindow.webContents.send(channel);
});
return;
}
targetWindow.webContents.send(channel);
}
function setupApplicationMenu() {
const isMac = process.platform === "darwin";
const template: Electron.MenuItemConstructorOptions[] = [];
if (isMac) {
template.push({
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
});
}
template.push(
{
label: mainT("common", "actions.file") || "File",
submenu: [
{
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
accelerator: "CmdOrCtrl+O",
click: () => sendEditorMenuAction("menu-load-project"),
},
{
label: mainT("dialogs", "unsavedChanges.saveProject") || "Save Project…",
accelerator: "CmdOrCtrl+S",
click: () => sendEditorMenuAction("menu-save-project"),
},
{
label: mainT("dialogs", "unsavedChanges.saveProjectAs") || "Save Project As…",
accelerator: "CmdOrCtrl+Shift+S",
click: () => sendEditorMenuAction("menu-save-project-as"),
},
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
],
},
{
label: mainT("common", "actions.edit") || "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: mainT("common", "actions.view") || "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: mainT("common", "actions.window") || "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);
tray.on("click", () => {
showMainWindow();
});
tray.on("double-click", () => {
showMainWindow();
});
}
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: mainT("common", "actions.stopRecording") || "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
},
},
]
: [
{
label: mainT("common", "actions.open") || "Open",
click: () => {
showMainWindow();
},
},
{
label: mainT("common", "actions.quit") || "Quit",
click: () => {
app.quit();
},
},
];
tray.setImage(trayIcon);
tray.setToolTip(trayToolTip);
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
}
let editorHasUnsavedChanges = false;
let isForceClosing = false;
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
});
function forceCloseEditorWindow(windowToClose: BrowserWindow | null) {
if (!windowToClose || windowToClose.isDestroyed()) return;
isForceClosing = true;
setImmediate(() => {
try {
if (!windowToClose.isDestroyed()) {
windowToClose.close();
}
} finally {
isForceClosing = false;
}
});
}
function createEditorWindowWrapper() {
if (mainWindow) {
mainWindow.close()
mainWindow = null
}
mainWindow = createEditorWindow()
if (mainWindow) {
isForceClosing = true;
mainWindow.close();
isForceClosing = false;
mainWindow = null;
}
mainWindow = createEditorWindow();
editorHasUnsavedChanges = false;
mainWindow.on("close", (event) => {
if (isForceClosing || !editorHasUnsavedChanges) return;
event.preventDefault();
const choice = dialog.showMessageBoxSync(mainWindow!, {
type: "warning",
buttons: [
mainT("dialogs", "unsavedChanges.saveAndClose"),
mainT("dialogs", "unsavedChanges.discardAndClose"),
mainT("common", "actions.cancel"),
],
defaultId: 0,
cancelId: 2,
title: mainT("dialogs", "unsavedChanges.title"),
message: mainT("dialogs", "unsavedChanges.message"),
detail: mainT("dialogs", "unsavedChanges.detail"),
});
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === 0) {
// Save & Close — tell renderer to save, then close
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
if (!shouldClose) return;
forceCloseEditorWindow(windowToClose);
});
} else if (choice === 1) {
// Discard & Close
forceCloseEditorWindow(windowToClose);
}
// choice === 2: Cancel — do nothing, window stays open
});
}
function createSourceSelectorWindowWrapper() {
sourceSelectorWindow = createSourceSelectorWindow()
sourceSelectorWindow.on('closed', () => {
sourceSelectorWindow = null
})
return sourceSelectorWindow
sourceSelectorWindow = createSourceSelectorWindow();
sourceSelectorWindow.on("closed", () => {
sourceSelectorWindow = null;
});
return sourceSelectorWindow;
}
// On macOS, applications and their menu bar stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
// Keep app running (macOS behavior)
})
app.on("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()
})
// Allow microphone/media permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
return allowed.includes(permission);
});
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
callback(allowed.includes(permission));
});
// Request microphone permission from macOS
if (process.platform === "darwin") {
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
if (micStatus !== "granted") {
await systemPreferences.askForMediaAccess("microphone");
}
}
// Listen for HUD overlay quit event (macOS only)
ipcMain.on("hud-overlay-close", () => {
app.quit();
});
ipcMain.handle("set-locale", (_, locale: string) => {
setMainLocale(locale);
setupApplicationMenu();
updateTrayMenu();
});
createTray();
updateTrayMenu();
setupApplicationMenu();
// Ensure recordings directory exists
await ensureRecordingsDir();
function switchToHudWrapper() {
if (mainWindow) {
isForceClosing = true;
mainWindow.close();
isForceClosing = false;
mainWindow = null;
}
showMainWindow();
}
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName;
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
showMainWindow();
}
},
switchToHudWrapper,
);
createWindow();
});
+144 -51
View File
@@ -1,52 +1,145 @@
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer } from "electron";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
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");
},
switchToHud: () => {
return ipcRenderer.invoke("switch-to-hud");
},
startNewRecording: () => {
return ipcRenderer.invoke("start-new-recording");
},
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
selectSource: (source: ProcessedDesktopSource) => {
return ipcRenderer.invoke("select-source", source);
},
getSelectedSource: () => {
return ipcRenderer.invoke("get-selected-source");
},
requestCameraAccess: () => {
return ipcRenderer.invoke("request-camera-access");
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
return ipcRenderer.invoke("store-recorded-session", payload);
},
getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path");
},
setRecordingState: (recording: boolean) => {
return ipcRenderer.invoke("set-recording-state", recording);
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
},
onStopRecordingFromTray: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("stop-recording-from-tray", listener);
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
},
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
},
setCurrentVideoPath: (path: string) => {
return ipcRenderer.invoke("set-current-video-path", path);
},
setCurrentRecordingSession: (session: RecordingSession | null) => {
return ipcRenderer.invoke("set-current-recording-session", session);
},
getCurrentVideoPath: () => {
return ipcRenderer.invoke("get-current-video-path");
},
getCurrentRecordingSession: () => {
return ipcRenderer.invoke("get-current-recording-session");
},
readBinaryFile: (filePath: string) => {
return ipcRenderer.invoke("read-binary-file", filePath);
},
clearCurrentVideoPath: () => {
return ipcRenderer.invoke("clear-current-video-path");
},
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
},
loadProjectFile: () => {
return ipcRenderer.invoke("load-project-file");
},
loadCurrentProjectFile: () => {
return ipcRenderer.invoke("load-current-project-file");
},
onMenuLoadProject: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-load-project", listener);
return () => ipcRenderer.removeListener("menu-load-project", listener);
},
onMenuSaveProject: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-save-project", listener);
return () => ipcRenderer.removeListener("menu-save-project", listener);
},
onMenuSaveProjectAs: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-save-project-as", listener);
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
},
getPlatform: () => {
return ipcRenderer.invoke("get-platform");
},
revealInFolder: (filePath: string) => {
return ipcRenderer.invoke("reveal-in-folder", filePath);
},
getShortcuts: () => {
return ipcRenderer.invoke("get-shortcuts");
},
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke("save-shortcuts", shortcuts);
},
setLocale: (locale: string) => {
return ipcRenderer.invoke("set-locale", locale);
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
setHasUnsavedChanges: (hasChanges: boolean) => {
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
},
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
const listener = async () => {
try {
const shouldClose = await callback();
ipcRenderer.send("save-before-close-done", shouldClose);
} catch {
ipcRenderer.send("save-before-close-done", false);
}
};
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
},
});
+133 -99
View File
@@ -1,120 +1,154 @@
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");
const HEADLESS = process.env["HEADLESS"] === "true";
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 = 600;
const windowHeight = 160;
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: 600,
maxWidth: 600,
minHeight: 160,
maxHeight: 160,
x: x,
y: y,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
show: !HEADLESS,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
},
});
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",
show: !HEADLESS,
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

+16684 -10442
View File
File diff suppressed because it is too large Load Diff
+110 -58
View File
@@ -1,59 +1,111 @@
{
"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.3.0",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
"node": "22.22.1",
"npm": "10.9.4"
},
"author": {
"name": "Sid",
"email": "svaddem@asu.edu"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"i18n:check": "node scripts/i18n-check.mjs",
"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 AppImage deb",
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
"test:e2e": "playwright test",
"prepare": "husky"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gif.js": "^0.2.0",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"pixi-filters": "^6.1.5",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"react-rnd": "^10.5.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@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",
"@vitest/browser": "^4.0.16",
"@vitest/browser-playwright": "^4.0.16",
"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",
"jsdom": "^29.0.1",
"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"
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 120_000, // GIF encoding is CPU-bound; give it room
retries: 0,
reporter: "list",
});
+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: 460 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 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: 581 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: 439 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

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: 2.1 MiB

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.
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Validates that all locale translation files have identical key structures.
* Compares zh-CN and es against the en baseline for every namespace.
*
* Usage: node scripts/i18n-check.mjs
*/
import fs from "node:fs";
import path from "node:path";
const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
const COMPARE_LOCALES = ["zh-CN", "es", "tr"];
function getKeys(obj, prefix = "") {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
keys.push(...getKeys(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys.sort();
}
let hasErrors = false;
const baseDir = path.join(LOCALES_DIR, BASE_LOCALE);
const namespaces = fs
.readdirSync(baseDir)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""));
for (const namespace of namespaces) {
const basePath = path.join(baseDir, `${namespace}.json`);
const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8"));
const baseKeys = getKeys(baseData);
for (const locale of COMPARE_LOCALES) {
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
if (!fs.existsSync(localePath)) {
console.error(`MISSING: ${locale}/${namespace}.json does not exist`);
hasErrors = true;
continue;
}
const localeData = JSON.parse(fs.readFileSync(localePath, "utf-8"));
const localeKeys = getKeys(localeData);
const missing = baseKeys.filter((k) => !localeKeys.includes(k));
const extra = localeKeys.filter((k) => !baseKeys.includes(k));
if (missing.length > 0) {
console.error(`MISSING in ${locale}/${namespace}.json:`);
for (const key of missing) {
console.error(` - ${key}`);
}
hasErrors = true;
}
if (extra.length > 0) {
console.error(`EXTRA in ${locale}/${namespace}.json:`);
for (const key of extra) {
console.error(` + ${key}`);
}
hasErrors = true;
}
}
}
if (hasErrors) {
console.error("\ni18n check FAILED — translation files are out of sync.");
process.exit(1);
} else {
console.log(
`i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
);
}
+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;
}
+58 -34
View File
@@ -1,34 +1,58 @@
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 { Toaster } from "./components/ui/sonner";
import { TooltipProvider } from "./components/ui/tooltip";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
import VideoEditor from "./components/video-editor/VideoEditor";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { loadAllCustomFonts } from "./lib/customFonts";
export default function App() {
const [windowType, setWindowType] = useState("");
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const type = params.get("windowType") || "";
setWindowType(type);
if (type === "hud-overlay" || type === "source-selector") {
document.body.style.background = "transparent";
document.documentElement.style.background = "transparent";
document.getElementById("root")?.style.setProperty("background", "transparent");
}
// Load custom fonts on app initialization
loadAllCustomFonts().catch((error) => {
console.error("Failed to load custom fonts:", error);
});
}, []);
const content = (() => {
switch (windowType) {
case "hud-overlay":
return <LaunchWindow />;
case "source-selector":
return <SourceSelector />;
case "editor":
return (
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
</ShortcutsProvider>
);
default:
return (
<div className="w-full h-full bg-background text-foreground">
<h1>Openscreen</h1>
</div>
);
}
})();
return (
<TooltipProvider>
{content}
<Toaster theme="dark" className="pointer-events-auto" />
</TooltipProvider>
);
}
@@ -1,6 +1,8 @@
/* Electron-specific: no Tailwind equivalent for -webkit-app-region */
.electronDrag {
-webkit-app-region: drag;
-webkit-app-region: drag;
}
.electronNoDrag {
-webkit-app-region: no-drag;
-webkit-app-region: no-drag;
}
+527 -82
View File
@@ -1,93 +1,538 @@
import { useState, useEffect } from "react";
import styles from "./LaunchWindow.module.css";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { Button } from "../ui/button";
import { BsRecordCircle } from "react-icons/bs";
import { ChevronDown, Languages } from "lucide-react";
import { useEffect, useState } from "react";
import { BsPauseCircle, BsPlayCircle, 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 {
MdCancel,
MdMic,
MdMicOff,
MdMonitor,
MdRestartAlt,
MdVideocam,
MdVideocamOff,
MdVideoFile,
MdVolumeOff,
MdVolumeUp,
} from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
import { getLocaleName } from "@/i18n/loader";
import { isMac as getIsMac } from "@/utils/platformUtils";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { requestCameraAccess } from "../../lib/requestCameraAccess";
import { formatTimePadded } from "../../utils/timeUtils";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { Tooltip } from "../ui/tooltip";
import styles from "./LaunchWindow.module.css";
const ICON_SIZE = 20;
const ICON_CONFIG = {
drag: { icon: RxDragHandleDots2, size: ICON_SIZE },
monitor: { icon: MdMonitor, size: ICON_SIZE },
volumeOn: { icon: MdVolumeUp, size: ICON_SIZE },
volumeOff: { icon: MdVolumeOff, size: ICON_SIZE },
micOn: { icon: MdMic, size: ICON_SIZE },
micOff: { icon: MdMicOff, size: ICON_SIZE },
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
pause: { icon: BsPauseCircle, size: ICON_SIZE },
resume: { icon: BsPlayCircle, size: ICON_SIZE },
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
restart: { icon: MdRestartAlt, size: ICON_SIZE },
cancel: { icon: MdCancel, size: ICON_SIZE },
record: { icon: BsRecordCircle, size: ICON_SIZE },
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
folder: { icon: FaFolderOpen, size: ICON_SIZE },
minimize: { icon: FiMinus, size: ICON_SIZE },
close: { icon: FiX, size: ICON_SIZE },
} as const;
type IconName = keyof typeof ICON_CONFIG;
function getIcon(name: IconName, className?: string) {
const { icon: Icon, size } = ICON_CONFIG[name];
return <Icon size={size} className={className} />;
}
const hudGroupClasses =
"flex items-center gap-0.5 bg-white/5 rounded-full transition-colors duration-150 hover:bg-white/[0.08]";
const hudIconBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
const windowBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
export function LaunchWindow() {
const { recording, toggleRecording } = useScreenRecorder();
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const t = useScopedT("launch");
const { locale, setLocale } = useI18n();
const [isMac, setIsMac] = useState(false);
useEffect(() => {
const checkSelectedSource = async () => {
if (window.electronAPI) {
const source = await window.electronAPI.getSelectedSource();
if (source) {
setSelectedSource(source.name);
setHasSelectedSource(true);
} else {
setSelectedSource("Screen");
setHasSelectedSource(false);
}
}
};
useEffect(() => {
getIsMac().then(setIsMac);
}, []);
checkSelectedSource();
const interval = setInterval(checkSelectedSource, 500);
return () => clearInterval(interval);
}, []);
const {
recording,
paused,
elapsedSeconds,
toggleRecording,
togglePaused,
restartRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
webcamDeviceId,
setWebcamDeviceId,
} = useScreenRecorder();
const truncateText = (text: string, maxLength: number = 6) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
};
const showMicControls = microphoneEnabled && !recording;
const showWebcamControls = webcamEnabled && !recording;
const openSourceSelector = () => {
if (window.electronAPI) {
window.electronAPI.openSourceSelector();
}
};
const [isMicHovered, setIsMicHovered] = useState(false);
const [isMicFocused, setIsMicFocused] = useState(false);
const micExpanded = isMicHovered || isMicFocused;
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>
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
<div className="w-px h-5 bg-white/30" />
const {
devices: micDevices,
selectedDeviceId: selectedMicId,
setSelectedDeviceId: setSelectedMicId,
} = useMicrophoneDevices(microphoneEnabled);
const {
devices: cameraDevices,
selectedDeviceId: selectedCameraId,
setSelectedDeviceId: setSelectedCameraId,
isLoading: isCameraDevicesLoading,
error: cameraDevicesError,
} = useCameraDevices(webcamEnabled);
<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 selectedMicLabel =
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
t("audio.defaultMicrophone");
const selectedCameraLabel = isCameraDevicesLoading
? t("webcam.searching")
: cameraDevicesError
? t("webcam.unavailable")
: cameraDevices.length === 0
? t("webcam.noneFound")
: cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
t("webcam.defaultCamera");
const { level } = useAudioLevelMeter({
enabled: showMicControls,
deviceId: microphoneDeviceId,
});
useEffect(() => {
if (selectedMicId && selectedMicId !== "default") {
setMicrophoneDeviceId(selectedMicId);
}
}, [selectedMicId, setMicrophoneDeviceId]);
useEffect(() => {
if (selectedCameraId) {
setWebcamDeviceId(selectedCameraId);
}
}, [selectedCameraId, setWebcamDeviceId]);
useEffect(() => {
if (!import.meta.env.DEV) {
return;
}
void requestCameraAccess().catch((error) => {
console.warn("Failed to trigger camera access request during development:", error);
});
}, []);
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
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);
}
}
};
checkSelectedSource();
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-screen h-screen bg-transparent ${styles.electronDrag}`}>
{/* Language switcher — top-left, beside traffic lights */}
<div
className={`fixed top-2 flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
>
<Languages size={14} />
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
style={{ color: "inherit" }}
>
{SUPPORTED_LOCALES.map((loc) => (
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
{getLocaleName(loc)}
</option>
))}
</select>
</div>
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
{(showMicControls || showWebcamControls) && (
<div
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
>
{/* Mic selector */}
{showMicControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsMicHovered(true)}
onMouseLeave={() => setIsMicHovered(false)}
onFocus={() => setIsMicFocused(true)}
onBlur={() => setIsMicFocused(false)}
style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
>
<div className="relative flex-1 min-w-0">
{!micExpanded && (
<div className="text-white/60 text-[10px] font-medium truncate">
{selectedMicLabel}
</div>
)}
<select
value={microphoneDeviceId || selectedMicId}
onChange={(e) => {
setSelectedMicId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`}
>
{micDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId} className="bg-[#1c1c24]">
{device.label}
</option>
))}
</select>
{micExpanded && (
<ChevronDown
size={12}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
/>
)}
</div>
<AudioLevelMeter
level={level}
className={`${micExpanded ? "w-16" : "w-8"} h-2 transition-all duration-300`}
/>
</div>
)}
{/* Webcam selector */}
{showWebcamControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsWebcamHovered(true)}
onMouseLeave={() => setIsWebcamHovered(false)}
onFocus={() => setIsWebcamFocused(true)}
onBlur={() => setIsWebcamFocused(false)}
style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
>
<div className="relative flex-1 min-w-0">
{!webcamExpanded && (
<div className="text-white/60 text-[10px] font-medium truncate">
{selectedCameraLabel}
</div>
)}
{webcamExpanded &&
(isCameraDevicesLoading ? (
<span className="text-white/40 text-[10px] italic">
{t("webcam.searching")}
</span>
) : cameraDevicesError ? (
<span className="text-white/40 text-[10px] italic">
{t("webcam.unavailable")}
</span>
) : cameraDevices.length === 0 ? (
<span className="text-white/40 text-[10px] italic">
{t("webcam.noneFound")}
</span>
) : (
<>
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
}}
className="w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer"
>
{cameraDevices.map((device) => (
<option
key={device.deviceId}
value={device.deviceId}
className="bg-[#1c1c24]"
>
{device.label}
</option>
))}
</select>
<ChevronDown
size={12}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
/>
</>
))}
{(!webcamExpanded || cameraDevices.length === 0) && (
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
}}
className="sr-only"
>
{cameraDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
)}
</div>
</div>
)}
</div>
)}
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
<div
className={`fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1.5 rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]`}
>
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
{getIcon("drag", "text-white/30")}
</div>
{/* Source selector */}
<button
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
{getIcon("monitor", "text-white/80")}
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
</button>
{/* Audio controls group */}
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
<button
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={
systemAudioEnabled ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio")
}
>
{systemAudioEnabled
? getIcon("volumeOn", "text-green-400")
: getIcon("volumeOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
>
{microphoneEnabled
? getIcon("micOn", "text-green-400")
: getIcon("micOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
}}
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
>
{webcamEnabled
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
</div>
{/* Record/Stop group */}
<button
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
recording
? paused
? "bg-amber-500/10 hover:bg-amber-500/15"
: "animate-record-pulse bg-red-500/10"
: "bg-white/5 hover:bg-white/[0.08]"
}`}
onClick={toggleRecording}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
{recording ? (
<>
{getIcon("stop", paused ? "text-amber-400" : "text-red-400")}
<span
className={`${paused ? "text-amber-400" : "text-red-400"} text-xs font-semibold tabular-nums`}
>
{formatTimePadded(elapsedSeconds)}
</span>
</>
) : (
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
)}
</button>
{recording && (
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={togglePaused}
>
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
</button>
</Tooltip>
)}
{/* Restart recording */}
{recording && (
<Tooltip content={t("tooltips.restartRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={restartRecording}
>
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
)}
{/* Cancel recording */}
{recording && (
<Tooltip content={t("tooltips.cancelRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={cancelRecording}
>
{getIcon("cancel", "text-white/60")}
</button>
</Tooltip>
)}
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
>
{getIcon("videoFile", "text-white/60")}
</button>
</Tooltip>
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
>
{getIcon("folder", "text-white/60")}
</button>
</Tooltip>
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button
className={windowBtnClasses}
title={t("tooltips.hideHUD")}
onClick={sendHudOverlayHide}
>
{getIcon("minimize", "text-white")}
</button>
<button
className={windowBtnClasses}
title={t("tooltips.closeApp")}
onClick={sendHudOverlayClose}
>
{getIcon("close", "text-white")}
</button>
</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);
}
+150 -145
View File
@@ -1,159 +1,164 @@
import { useState, useEffect } from "react";
import { Button } from "../ui/button";
import { useEffect, useState } from "react";
import { MdCheck } from "react-icons/md";
import { useScopedT } from "@/contexts/I18nContext";
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 t = useScopedT("launch");
const tc = useScopedT("common");
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">{t("sourceSelector.loading")}</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={screenSources.length === 0 ? "windows" : "screens"}
className="flex-1 flex flex-col"
>
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
<TabsTrigger
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
{t("sourceSelector.screens", { count: String(screenSources.length) })}
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
{t("sourceSelector.windows", { count: String(windowSources.length) })}
</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0">
<TabsContent value="screens" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
>
{screenSources.map(renderSourceCard)}
</div>
</TabsContent>
<TabsContent value="windows" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
>
{windowSources.map(renderSourceCard)}
</div>
</TabsContent>
</div>
</Tabs>
</div>
<div className="p-3 flex justify-center gap-2">
<Button
variant="ghost"
onClick={() => window.close()}
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
>
{tc("actions.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"
>
{tc("actions.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 };
+70
View File
@@ -0,0 +1,70 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 200,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function TooltipRoot({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 6,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"px-2 py-1 text-[11px] leading-none text-white/90 bg-black/85 border border-white/10 rounded-md z-50",
"animate-in fade-in-0 zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
);
}
function Tooltip({
children,
content,
side,
className,
}: {
children: React.ReactNode;
content: React.ReactNode;
side?: "top" | "right" | "bottom" | "left";
className?: string;
}) {
return (
<TooltipRoot>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} className={className}>
{content}
</TooltipContent>
</TooltipRoot>
);
}
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, Tooltip };
@@ -0,0 +1,180 @@
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 { useScopedT } from "@/contexts/I18nContext";
import {
addCustomFont,
type CustomFont,
generateFontId,
isValidGoogleFontsUrl,
parseFontFamilyFromImport,
} from "@/lib/customFonts";
interface AddCustomFontDialogProps {
onFontAdded?: (font: CustomFont) => void;
}
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const t = useScopedT("settings");
const tc = useScopedT("common");
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(t("customFont.errorEmptyUrl"));
return;
}
if (!isValidGoogleFontsUrl(importUrl)) {
toast.error(t("customFont.errorInvalidUrl"));
return;
}
if (!fontName.trim()) {
toast.error(t("customFont.errorEmptyName"));
return;
}
setLoading(true);
try {
// Extract font family from URL
const fontFamily = parseFontFamilyFromImport(importUrl);
if (!fontFamily) {
toast.error(t("customFont.errorExtractFailed"));
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(t("customFont.successMessage", { fontName }));
// 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(t("customFont.failedToAdd"), {
description: errorMessage.includes("timeout")
? t("customFont.errorTimeout")
: t("customFont.errorLoadFailed"),
});
} 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" />
{t("customFont.dialogTitle")}
</Button>
</DialogTrigger>
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<DialogHeader>
<DialogTitle>{t("customFont.dialogTitle")}</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">
{t("customFont.urlLabel")}
</Label>
<Input
id="import-url"
placeholder={t("customFont.urlPlaceholder")}
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">{t("customFont.urlHelp")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="font-name" className="text-slate-200">
{t("customFont.nameLabel")}
</Label>
<Input
id="font-name"
placeholder={t("customFont.namePlaceholder")}
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">{t("customFont.nameHelp")}</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"
>
{tc("actions.cancel")}
</Button>
<Button
onClick={handleAdd}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loading ? t("customFont.addingButton") : t("customFont.addButton")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,456 @@
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
import { Rnd } from "react-rnd";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import {
type AnnotationRegion,
type BlurData,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_INTENSITY,
} from "./types";
const FREEHAND_POINT_THRESHOLD = 1;
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
if (points.length < 3) return undefined;
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
return `polygon(${polygon})`;
}
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
if (closed ? points.length < 3 : points.length < 2) return null;
const [firstPoint, ...rest] = points;
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
return closed ? `${path} Z` : path;
}
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;
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => 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,
onBlurDataChange,
onBlurDataCommit,
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 blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
const isDraggingRef = useRef(false);
const isDrawingFreehandRef = useRef(false);
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
[],
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
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 normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
return {
x: Math.max(0, Math.min(100, x)),
y: Math.max(0, Math.min(100, y)),
};
};
const appendFreehandPoint = (point: { x: number; y: number }) => {
const points = freehandPointsRef.current;
const lastPoint = points[points.length - 1];
if (!lastPoint) {
points.push(point);
return;
}
const dx = point.x - lastPoint.x;
const dy = point.y - lastPoint.y;
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
points.push(point);
}
};
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (
!isSelected ||
annotation.type !== "blur" ||
annotation.blurData?.shape !== "freehand" ||
!onBlurDataChange
) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
isDrawingFreehandRef.current = true;
setIsFreehandDrawing(true);
const point = normalizePoint(event);
freehandPointsRef.current = [point];
setDraftFreehandPoints([point]);
setLivePointerPoint(point);
};
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current) return;
event.preventDefault();
event.stopPropagation();
const point = normalizePoint(event);
setLivePointerPoint(point);
appendFreehandPoint(point);
setDraftFreehandPoints([...freehandPointsRef.current]);
};
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
isDrawingFreehandRef.current = false;
setIsFreehandDrawing(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// no-op if already released
}
const points = [...freehandPointsRef.current];
if (livePointerPoint) {
const last = points[points.length - 1];
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
points.push(livePointerPoint);
}
}
if (points.length >= 3) {
const closedPoints = [...points];
const first = closedPoints[0];
const last = closedPoints[closedPoints.length - 1];
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
closedPoints.push({ ...first });
}
onBlurDataChange(annotation.id, {
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
shape: "freehand",
freehandPoints: closedPoints,
});
setDraftFreehandPoints(closedPoints);
onBlurDataCommit?.();
}
setLivePointerPoint(null);
};
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>
);
case "blur": {
const shape = annotation.blurData?.shape ?? "rectangle";
const blurIntensity = Math.max(
1,
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
);
const activeFreehandPoints =
shape === "freehand"
? isFreehandDrawing
? draftFreehandPoints
: (annotation.blurData?.freehandPoints ?? [])
: [];
const drawingPoints =
isFreehandDrawing && livePointerPoint
? (() => {
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
if (!last) return [livePointerPoint];
const dx = livePointerPoint.x - last.x;
const dy = livePointerPoint.y - last.y;
return Math.hypot(dx, dy) > 0.01
? [...activeFreehandPoints, livePointerPoint]
: activeFreehandPoints;
})()
: activeFreehandPoints;
const clipPath =
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
const freehandPath =
shape === "freehand"
? buildBlurFreehandPath(
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
!isFreehandDrawing,
)
: null;
const currentPointerPoint = isFreehandDrawing
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
: null;
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
const shouldShowFreehandBlurFill =
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
const shapeMaskStyle: CSSProperties = {
borderRadius: shapeBorderRadius,
clipPath: isFreehandDrawing ? undefined : clipPath,
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
};
const isFreehandSelected = isSelectedFreehandBlur;
return (
<div className="w-full h-full relative">
<div
className="absolute inset-0 overflow-hidden"
style={{
...shapeMaskStyle,
isolation: "isolate",
}}
>
<div
className="absolute inset-0"
style={{
...shapeMaskStyle,
backdropFilter: `blur(${blurIntensity}px)`,
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
backgroundColor: "rgba(255, 255, 255, 0.02)",
opacity: shouldShowFreehandBlurFill ? 1 : 0,
}}
/>
{isSelected && shape !== "freehand" && (
<div
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
style={{ borderRadius: shapeBorderRadius }}
/>
)}
</div>
{isSelected && shape === "freehand" && freehandPath && (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 pointer-events-none"
>
<path
d={freehandPath}
fill="none"
stroke="#34B27B"
strokeWidth="0.55"
strokeLinecap="round"
strokeLinejoin="round"
/>
{currentPointerPoint && (
<circle
cx={currentPointerPoint.x}
cy={currentPointerPoint.y}
r="0.6"
fill="#34B27B"
/>
)}
</svg>
)}
{isFreehandSelected && (
<div
className="absolute inset-0 cursor-crosshair"
onPointerDown={handleFreehandPointerDown}
onPointerMove={handleFreehandPointerMove}
onPointerUp={finishFreehandPointer}
onPointerCancel={finishFreehandPointer}
/>
)}
</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",
isSelected &&
annotation.type !== "blur" &&
"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 && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
backgroundColor:
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
boxShadow:
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
}}
enableResizing={isSelected && !isSelectedFreehandBlur}
disableDragging={!isSelected || isSelectedFreehandBlur}
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",
annotation.type !== "blur" && "rounded-lg",
annotation.type === "text" && "bg-transparent",
annotation.type === "image" && "bg-transparent",
annotation.type === "figure" && "bg-transparent",
annotation.type === "blur" && "bg-transparent",
isSelected && annotation.type !== "blur" && "shadow-lg",
)}
>
{renderContent()}
</div>
</Rnd>
);
}
@@ -0,0 +1,629 @@
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 { useScopedT } from "@/contexts/I18nContext";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
import {
type AnnotationRegion,
type AnnotationType,
type ArrowDirection,
type 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", labelKey: "classic" },
{ value: "Georgia, serif", labelKey: "editor" },
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
{ value: "Courier New, monospace", labelKey: "typewriter" },
{ value: "Brush Script MT, cursive", labelKey: "deco" },
{ value: "Arial, sans-serif", labelKey: "simple" },
{ value: "Verdana, sans-serif", labelKey: "modern" },
{ value: "Trebuchet MS, sans-serif", labelKey: "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 t = useScopedT("settings");
const fileInputRef = useRef<HTMLInputElement>(null);
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
const fontStyleLabels: Record<string, string> = {
classic: t("fontStyles.classic"),
editor: t("fontStyles.editor"),
strong: t("fontStyles.strong"),
typewriter: t("fontStyles.typewriter"),
deco: t("fontStyles.deco"),
simple: t("fontStyles.simple"),
modern: t("fontStyles.modern"),
clean: t("fontStyles.clean"),
};
// 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(t("annotation.invalidImageType"), {
description: t("annotation.imageFormatsOnly"),
});
event.target.value = "";
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
onContentChange(dataUrl);
toast.success(t("annotation.imageUploadSuccess"));
}
};
reader.onerror = () => {
toast.error(t("annotation.failedImageUpload"), {
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">{t("annotation.title")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{t("annotation.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" />
{t("annotation.typeText")}
</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" />
{t("annotation.typeImage")}
</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>
{t("annotation.typeArrow")}
</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">
{t("annotation.textContent")}
</label>
<textarea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder={t("annotation.textPlaceholder")}
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">
{t("annotation.fontStyle")}
</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={t("annotation.selectStyle")} />
</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 }}
>
{fontStyleLabels[font.labelKey]}
</SelectItem>
))}
{customFonts.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
{t("annotation.customFonts")}
</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">
{t("annotation.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={t("annotation.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">
{t("annotation.textColor")}
</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">
{t("annotation.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"
? t("annotation.none")
: t("annotation.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" });
}}
>
{t("annotation.clearBackground")}
</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" />
{t("annotation.uploadImage")}
</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">
{t("annotation.supportedFormats")}
</p>
</TabsContent>
<TabsContent value="figure" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-3 block">
{t("annotation.arrowDirection")}
</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">
{t("annotation.strokeWidth", {
width: String(annotation.figureData?.strokeWidth || 4),
})}
</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">
{t("annotation.arrowColor")}
</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" />
{t("annotation.deleteAnnotation")}
</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">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
<li>{t("annotation.tipTabCycle")}</li>
<li>{t("annotation.tipShiftTabCycle")}</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;
}
}
@@ -0,0 +1,142 @@
import { Info, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import {
type AnnotationRegion,
type BlurData,
type BlurShape,
DEFAULT_BLUR_DATA,
MAX_BLUR_INTENSITY,
MIN_BLUR_INTENSITY,
} from "./types";
interface BlurSettingsPanelProps {
blurRegion: AnnotationRegion;
onBlurDataChange: (blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onDelete: () => void;
}
export function BlurSettingsPanel({
blurRegion,
onBlurDataChange,
onBlurDataCommit,
onDelete,
}: BlurSettingsPanelProps) {
const t = useScopedT("settings");
const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
{ value: "rectangle", labelKey: "blurShapeRectangle" },
{ value: "oval", labelKey: "blurShapeOval" },
];
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">{t("annotation.blurShape")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{t("annotation.active")}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{blurShapeOptions.map((shape) => {
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
const isActive = activeShape === shape.value;
return (
<button
key={shape.value}
onClick={() => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
shape: shape.value,
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
className={cn(
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
isActive
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
)}
>
{shape.value === "rectangle" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-sm",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
{shape.value === "oval" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-full",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
<span className="text-[10px] leading-none">
{t(`annotation.${shape.labelKey}`)}
</span>
</button>
);
})}
</div>
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">
{t("annotation.blurIntensity")}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px
</span>
</div>
<Slider
value={[blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity]}
onValueChange={(values) => {
onBlurDataChange({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
intensity: values[0],
});
}}
onValueCommit={() => onBlurDataCommit?.()}
min={MIN_BLUR_INTENSITY}
max={MAX_BLUR_INTENSITY}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<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" />
{t("annotation.deleteAnnotation")}
</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">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
</ul>
</div>
</div>
</div>
);
}
+221 -190
View File
@@ -1,221 +1,252 @@
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" | "move" | null;
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState<DragHandle>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState<DragHandle>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
useEffect(() => {
if (!videoElement || !canvasRef.current) return;
useEffect(() => {
if (!videoElement || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
canvas.width = videoElement.videoWidth || 1920;
canvas.height = videoElement.videoHeight || 1080;
canvas.width = videoElement.videoWidth || 1920;
canvas.height = videoElement.videoHeight || 1080;
const draw = () => {
if (videoElement.readyState >= 2) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
}
requestAnimationFrame(draw);
};
const draw = () => {
if (videoElement.readyState >= 2) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
}
requestAnimationFrame(draw);
};
const rafId = requestAnimationFrame(draw);
return () => cancelAnimationFrame(rafId);
}, [videoElement]);
const rafId = requestAnimationFrame(draw);
return () => cancelAnimationFrame(rafId);
}, [videoElement]);
const getContainerRect = () => {
return containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 };
};
const getContainerRect = () => {
return (
containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 }
);
};
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
e.stopPropagation();
e.preventDefault();
setIsDragging(handle);
const rect = getContainerRect();
setDragStart({
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
});
setInitialCrop(cropRegion);
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
e.stopPropagation();
e.preventDefault();
setIsDragging(handle);
const rect = getContainerRect();
setDragStart({
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
});
setInitialCrop(cropRegion);
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging) return;
e.currentTarget.setPointerCapture(e.pointerId);
};
const rect = getContainerRect();
const currentX = (e.clientX - rect.left) / rect.width;
const currentY = (e.clientY - rect.top) / rect.height;
const deltaX = currentX - dragStart.x;
const deltaY = currentY - dragStart.y;
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging) return;
let newCrop = { ...initialCrop };
const rect = getContainerRect();
const currentX = (e.clientX - rect.left) / rect.width;
const currentY = (e.clientY - rect.top) / rect.height;
const deltaX = currentX - dragStart.x;
const deltaY = currentY - dragStart.y;
switch (isDragging) {
case 'top': {
const newY = Math.max(0, initialCrop.y + deltaY);
const bottom = initialCrop.y + initialCrop.height;
newCrop.y = Math.min(newY, bottom - 0.1);
newCrop.height = bottom - newCrop.y;
break;
}
case 'bottom':
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
break;
case 'left': {
const newX = Math.max(0, initialCrop.x + deltaX);
const right = initialCrop.x + initialCrop.width;
newCrop.x = Math.min(newX, right - 0.1);
newCrop.width = right - newCrop.x;
break;
}
case 'right':
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
break;
}
let newCrop = { ...initialCrop };
onCropChange(newCrop);
};
switch (isDragging) {
case "top": {
const newY = Math.max(0, initialCrop.y + deltaY);
const bottom = initialCrop.y + initialCrop.height;
newCrop.y = Math.min(newY, bottom - 0.1);
newCrop.height = bottom - newCrop.y;
break;
}
case "bottom":
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
break;
case "left": {
const newX = Math.max(0, initialCrop.x + deltaX);
const right = initialCrop.x + initialCrop.width;
newCrop.x = Math.min(newX, right - 0.1);
newCrop.width = right - newCrop.x;
break;
}
case "right":
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
break;
case "move": {
newCrop.x = Math.max(0, Math.min(initialCrop.x + deltaX, 1 - initialCrop.width));
newCrop.y = Math.max(0, Math.min(initialCrop.y + deltaY, 1 - initialCrop.height));
break;
}
}
const handlePointerUp = (e: React.PointerEvent) => {
if (isDragging) {
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
}
}
setIsDragging(null);
};
onCropChange(newCrop);
};
const cropPixelX = cropRegion.x * 100;
const cropPixelY = cropRegion.y * 100;
const cropPixelWidth = cropRegion.width * 100;
const cropPixelHeight = cropRegion.height * 100;
const handlePointerUp = (e: React.PointerEvent) => {
if (isDragging) {
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// Pointer may already be released; ignore.
}
}
setIsDragging(null);
};
return (
<div className="w-full p-8">
<div
ref={containerRef}
className="relative w-full 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="absolute z-10 pointer-events-auto cursor-move"
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
width: `${cropPixelWidth}%`,
height: `${cropPixelHeight}%`,
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "move")}
/>
<div
className={cn(
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]"
)}
style={{
left: `${cropPixelX + cropPixelWidth}%`,
top: `${cropPixelY}%`,
height: `${cropPixelHeight}%`,
transform: 'translateX(-50%)',
willChange: 'transform',
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'right')}
/>
</div>
</div>
);
<div
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
width: `${cropPixelWidth}%`,
transform: "translateY(-50%)",
willChange: "transform",
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "top")}
/>
<div
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY + cropPixelHeight}%`,
width: `${cropPixelWidth}%`,
transform: "translateY(-50%)",
willChange: "transform",
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "bottom")}
/>
<div
className={cn("absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
height: `${cropPixelHeight}%`,
transform: "translateX(-50%)",
willChange: "transform",
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "left")}
/>
<div
className={cn("absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
left: `${cropPixelX + cropPixelWidth}%`,
top: `${cropPixelY}%`,
height: `${cropPixelHeight}%`,
transform: "translateX(-50%)",
willChange: "transform",
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "right")}
/>
</div>
</div>
);
}

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