diff --git a/apps/desktop/src-tauri/src/frame_ws.rs b/apps/desktop/src-tauri/src/frame_ws.rs index 1276f21087..69bb1bb6e1 100644 --- a/apps/desktop/src-tauri/src/frame_ws.rs +++ b/apps/desktop/src-tauri/src/frame_ws.rs @@ -1,8 +1,9 @@ use std::sync::Arc; -use flume::Receiver; +use tokio::sync::watch; use tokio_util::sync::CancellationToken; +#[derive(Clone)] pub struct WSFrame { pub data: Vec, pub width: u32, @@ -10,7 +11,9 @@ pub struct WSFrame { pub stride: u32, } -pub async fn create_frame_ws(frame_rx: Receiver) -> (u16, CancellationToken) { +pub async fn create_watch_frame_ws( + frame_rx: watch::Receiver>, +) -> (u16, CancellationToken) { use axum::{ extract::{ State, @@ -19,9 +22,8 @@ pub async fn create_frame_ws(frame_rx: Receiver) -> (u16, CancellationT response::IntoResponse, routing::get, }; - use tokio::sync::Mutex; - type RouterState = Arc>>; + type RouterState = watch::Receiver>; #[axum::debug_handler] async fn ws_handler( @@ -31,17 +33,133 @@ pub async fn create_frame_ws(frame_rx: Receiver) -> (u16, CancellationT ws.on_upgrade(move |socket| handle_socket(socket, state)) } - async fn handle_socket(mut socket: WebSocket, state: RouterState) { - let camera_rx = state.lock().await; + async fn handle_socket(mut socket: WebSocket, mut camera_rx: RouterState) { + println!("socket connection established"); + tracing::info!("Socket connection established"); + let now = std::time::Instant::now(); + + // Send the current frame immediately upon connection (if one exists) + // This ensures the client doesn't wait for the next config change to see the image + { + let frame_opt = camera_rx.borrow().clone(); + if let Some(mut frame) = frame_opt { + frame.data.extend_from_slice(&frame.stride.to_le_bytes()); + frame.data.extend_from_slice(&frame.height.to_le_bytes()); + frame.data.extend_from_slice(&frame.width.to_le_bytes()); + + if let Err(e) = socket.send(Message::Binary(frame.data)).await { + tracing::error!("Failed to send initial frame to socket: {:?}", e); + return; + } + } + } + + loop { + tokio::select! { + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => { + tracing::info!("WebSocket closed"); + break; + } + Some(Ok(_)) => { + tracing::info!("Received message from socket (ignoring)"); + } + Some(Err(e)) => { + tracing::error!("WebSocket error: {:?}", e); + break; + } + } + }, + res = camera_rx.changed() => { + if res.is_err() { + tracing::error!("Camera channel closed"); + break; + } + let frame_opt = camera_rx.borrow().clone(); + if let Some(mut frame) = frame_opt { + frame.data.extend_from_slice(&frame.stride.to_le_bytes()); + frame.data.extend_from_slice(&frame.height.to_le_bytes()); + frame.data.extend_from_slice(&frame.width.to_le_bytes()); + + if let Err(e) = socket.send(Message::Binary(frame.data)).await { + tracing::error!("Failed to send frame to socket: {:?}", e); + break; + } + } + } + } + } + + let elapsed = now.elapsed(); + println!("Websocket closing after {elapsed:.2?}"); + tracing::info!("Websocket closing after {elapsed:.2?}"); + } + + let router = axum::Router::new() + .route("/", get(ws_handler)) + .with_state(frame_rx); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tracing::info!("WebSocket server listening on port {}", port); + + let cancel_token = CancellationToken::new(); + let cancel_token_child = cancel_token.child_token(); + tokio::spawn(async move { + let server = axum::serve(listener, router.into_make_service()); + tokio::select! { + _ = server => {}, + _ = cancel_token.cancelled() => { + println!("WebSocket server shutting down"); + } + } + }); + + (port, cancel_token_child) +} + +pub async fn create_frame_ws(frame_rx: flume::Receiver) -> (u16, CancellationToken) { + use axum::{ + extract::{ + State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::IntoResponse, + routing::get, + }; + + type RouterState = Arc>; + + #[axum::debug_handler] + async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State, + ) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state)) + } + + async fn handle_socket(mut socket: WebSocket, camera_rx: RouterState) { println!("socket connection established"); tracing::info!("Socket connection established"); let now = std::time::Instant::now(); loop { tokio::select! { - _ = socket.recv() => { - tracing::info!("Received message from socket"); - break; + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => { + tracing::info!("WebSocket closed"); + break; + } + Some(Ok(_)) => { + tracing::info!("Received message from socket (ignoring)"); + } + Some(Err(e)) => { + tracing::error!("WebSocket error: {:?}", e); + break; + } + } }, incoming_frame = camera_rx.recv_async() => { match incoming_frame { @@ -74,7 +192,7 @@ pub async fn create_frame_ws(frame_rx: Receiver) -> (u16, CancellationT let router = axum::Router::new() .route("/", get(ws_handler)) - .with_state(Arc::new(Mutex::new(frame_rx))); + .with_state(Arc::new(frame_rx)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e3c3f1ffa0..d3381b1735 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ mod posthog; mod presets; mod recording; mod recording_settings; +mod screenshot_editor; mod target_select_overlay; mod thumbnails; mod tray; @@ -58,6 +59,9 @@ use kameo::{Actor, actor::ActorRef}; use notifications::NotificationType; use recording::{InProgressRecording, RecordingEvent, RecordingInputKind}; use scap_targets::{Display, DisplayId, WindowId, bounds::LogicalBounds}; +use screenshot_editor::{ + ScreenshotEditorInstances, create_screenshot_editor_instance, update_screenshot_config, +}; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; @@ -84,7 +88,9 @@ use tracing::*; use upload::{create_or_get_video, upload_image, upload_video}; use web_api::AuthedApiError; use web_api::ManagerExt as WebManagerExt; -use windows::{CapWindowId, EditorWindowIds, ShowCapWindow, set_window_transparent}; +use windows::{ + CapWindowId, EditorWindowIds, ScreenshotEditorWindowIds, ShowCapWindow, set_window_transparent, +}; use crate::{ camera::CameraPreviewManager, @@ -1107,6 +1113,25 @@ async fn copy_screenshot_to_clipboard( Ok(()) } +#[tauri::command] +#[specta::specta] +#[instrument(skip(clipboard, data))] +async fn copy_image_to_clipboard( + clipboard: MutableState<'_, ClipboardContext>, + data: Vec, +) -> Result<(), String> { + println!("Copying image to clipboard ({} bytes)", data.len()); + + let img_data = clipboard_rs::RustImageData::from_bytes(&data) + .map_err(|e| format!("Failed to create image data from bytes: {e}"))?; + clipboard + .write() + .await + .set_image(img_data) + .map_err(|err| format!("Failed to copy image to clipboard: {err}"))?; + Ok(()) +} + #[tauri::command] #[specta::specta] #[instrument(skip(_app))] @@ -2223,6 +2248,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { recording::resume_recording, recording::restart_recording, recording::delete_recording, + recording::take_screenshot, recording::list_cameras, recording::list_capture_windows, recording::list_capture_displays, @@ -2241,6 +2267,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { copy_file_to_path, copy_video_to_clipboard, copy_screenshot_to_clipboard, + copy_image_to_clipboard, open_file_path, get_video_metadata, create_editor_instance, @@ -2256,6 +2283,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { permissions::request_permission, upload_exported_video, upload_screenshot, + create_screenshot_editor_instance, + update_screenshot_config, get_recording_meta, save_file_dialog, list_recordings, @@ -2431,6 +2460,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { ]) .map_label(|label| match label { label if label.starts_with("editor-") => "editor", + label if label.starts_with("screenshot-editor-") => "screenshot-editor", label if label.starts_with("window-capture-occluder-") => { "window-capture-occluder" } @@ -2448,6 +2478,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { fake_window::init(&app); app.manage(target_select_overlay::WindowFocusManager::default()); app.manage(EditorWindowIds::default()); + app.manage(ScreenshotEditorWindowIds::default()); #[cfg(target_os = "macos")] app.manage(crate::platform::ScreenCapturePrewarmer::default()); app.manage(http_client::HttpClient::default()); @@ -2682,6 +2713,18 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { reopen_main_window(&app); } } + CapWindowId::ScreenshotEditor { id } => { + let window_ids = + ScreenshotEditorWindowIds::get(window.app_handle()); + window_ids.ids.lock().unwrap().retain(|(_, _id)| *_id != id); + + tokio::spawn(ScreenshotEditorInstances::remove(window.clone())); + + #[cfg(target_os = "windows")] + if CapWindowId::Settings.get(&app).is_none() { + reopen_main_window(&app); + } + } CapWindowId::Settings => { for (label, window) in app.webview_windows() { if let Ok(id) = CapWindowId::from_str(&label) @@ -2792,6 +2835,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { tauri::RunEvent::Reopen { .. } => { let has_window = _handle.webview_windows().iter().any(|(label, _)| { label.starts_with("editor-") + || label.starts_with("screenshot-editor-") || label.as_str() == "settings" || label.as_str() == "signin" }); @@ -2802,6 +2846,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .iter() .find(|(label, _)| { label.starts_with("editor-") + || label.starts_with("screenshot-editor-") || label.as_str() == "settings" || label.as_str() == "signin" }) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 1c821f87b2..b3a87ceb87 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -441,6 +441,7 @@ pub async fn start_recording( } } RecordingMode::Studio => None, + RecordingMode::Screenshot => return Err("Use take_screenshot for screenshots".to_string()), }; let date_time = if cfg!(windows) { @@ -467,6 +468,9 @@ pub async fn start_recording( RecordingMode::Instant => { RecordingMetaInner::Instant(InstantRecordingMeta::InProgress { recording: true }) } + RecordingMode::Screenshot => { + return Err("Use take_screenshot for screenshots".to_string()); + } }, sharing: None, upload: None, @@ -725,6 +729,9 @@ pub async fn start_recording( camera_feed: camera_feed.clone(), }) } + RecordingMode::Screenshot => Err(anyhow!( + "Screenshot mode should be handled via take_screenshot" + )), } } .await; @@ -1014,6 +1021,104 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R Ok(()) } +#[tauri::command(async)] +#[specta::specta] +#[tracing::instrument(name = "take_screenshot", skip(app))] +pub async fn take_screenshot( + app: AppHandle, + target: ScreenCaptureTarget, +) -> Result { + use crate::NewScreenshotAdded; + use crate::notifications; + use cap_recording::screenshot::capture_screenshot; + use image::ImageEncoder; + + let image = capture_screenshot(target) + .await + .map_err(|e| format!("Failed to capture screenshot: {e}"))?; + + let screenshots_dir = app.path().app_data_dir().unwrap().join("screenshots"); + + std::fs::create_dir_all(&screenshots_dir).map_err(|e| e.to_string())?; + + let date_time = if cfg!(windows) { + chrono::Local::now().format("%Y-%m-%d %H.%M.%S") + } else { + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + }; + + let id = uuid::Uuid::new_v4().to_string(); + let cap_dir = screenshots_dir.join(format!("{id}.cap")); + std::fs::create_dir_all(&cap_dir).map_err(|e| e.to_string())?; + + let image_filename = "original.png"; + let image_path = cap_dir.join(image_filename); + + let file = std::fs::File::create(&image_path) + .map_err(|e| format!("Failed to create screenshot file: {e}"))?; + + // Use Best compression to keep file size small while maintaining lossless quality + let encoder = image::codecs::png::PngEncoder::new_with_quality( + file, + image::codecs::png::CompressionType::Best, + image::codecs::png::FilterType::Adaptive, + ); + + image::ImageEncoder::write_image( + encoder, + image.as_raw(), + image.width(), + image.height(), + image::ColorType::Rgb8.into(), + ) + .map_err(|e| format!("Failed to save screenshot: {e}"))?; + + // Create metadata + let relative_path = relative_path::RelativePathBuf::from(image_filename); + + let video_meta = cap_project::VideoMeta { + path: relative_path, + fps: 0, + start_time: Some(0.0), + }; + + let segment = cap_project::SingleSegment { + display: video_meta, + camera: None, + audio: None, + cursor: None, + }; + + let meta = cap_project::RecordingMeta { + platform: Some(Platform::default()), + project_path: cap_dir.clone(), + pretty_name: format!("Screenshot {}", date_time), + sharing: None, + inner: cap_project::RecordingMetaInner::Studio( + cap_project::StudioRecordingMeta::SingleSegment { segment }, + ), + upload: None, + }; + + meta.save_for_project() + .map_err(|e| format!("Failed to save recording meta: {e}"))?; + + cap_project::ProjectConfiguration::default() + .write(&cap_dir) + .map_err(|e| format!("Failed to save project config: {e}"))?; + + let _ = NewScreenshotAdded { + path: image_path.clone(), + } + .emit(&app); + + notifications::send_notification(&app, notifications::NotificationType::ScreenshotSaved); + + AppSounds::StopRecording.play(); + + Ok(image_path) +} + // runs when a recording ends, whether from success or failure async fn handle_recording_end( handle: AppHandle, diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs new file mode 100644 index 0000000000..f8aa5a12df --- /dev/null +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -0,0 +1,358 @@ +use crate::frame_ws::{WSFrame, create_watch_frame_ws}; +use crate::windows::{CapWindowId, ScreenshotEditorWindowIds}; +use cap_project::{ + ProjectConfiguration, RecordingMeta, RecordingMetaInner, SingleSegment, StudioRecordingMeta, + VideoMeta, +}; +use cap_rendering::{ + DecodedFrame, DecodedSegmentFrames, FrameRenderer, ProjectUniforms, RenderVideoConstants, + RendererLayers, +}; +use image::GenericImageView; +use relative_path::RelativePathBuf; +use serde::Serialize; +use specta::Type; +use std::str::FromStr; +use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc}; +use tauri::{Manager, Runtime, Window, ipc::CommandArg}; +use tokio::sync::{RwLock, watch}; +use tokio_util::sync::CancellationToken; + +pub struct ScreenshotEditorInstance { + pub ws_port: u16, + pub ws_shutdown_token: CancellationToken, + pub config_tx: watch::Sender, + pub path: PathBuf, +} + +impl ScreenshotEditorInstance { + pub async fn dispose(&self) { + self.ws_shutdown_token.cancel(); + } +} + +#[derive(Clone)] +pub struct ScreenshotEditorInstances(Arc>>>); + +pub struct WindowScreenshotEditorInstance(pub Arc); + +impl specta::function::FunctionArg for WindowScreenshotEditorInstance { + fn to_datatype(_: &mut specta::TypeMap) -> Option { + None + } +} + +impl Deref for WindowScreenshotEditorInstance { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'de, R: Runtime> CommandArg<'de, R> for WindowScreenshotEditorInstance { + fn from_command( + command: tauri::ipc::CommandItem<'de, R>, + ) -> Result { + let window = Window::from_command(command)?; + + let instances = window.state::(); + let instance = futures::executor::block_on(instances.0.read()); + + Ok(Self(instance.get(window.label()).cloned().unwrap())) + } +} + +impl ScreenshotEditorInstances { + pub async fn get_or_create( + window: &Window, + path: PathBuf, + ) -> Result, String> { + let instances = match window.try_state::() { + Some(s) => (*s).clone(), + None => { + let instances = Self(Arc::new(RwLock::new(HashMap::new()))); + window.manage(instances.clone()); + instances + } + }; + + let mut instances = instances.0.write().await; + + use std::collections::hash_map::Entry; + + match instances.entry(window.label().to_string()) { + Entry::Vacant(entry) => { + let (frame_tx, frame_rx) = watch::channel(None); + let (ws_port, ws_shutdown_token) = create_watch_frame_ws(frame_rx).await; + + // Load image + let img = image::open(&path).map_err(|e| format!("Failed to open image: {e}"))?; + let (width, height) = img.dimensions(); + let img_rgba = img.to_rgba8(); + let data = img_rgba.into_raw(); + + // Try to load existing meta if in a .cap directory + let (recording_meta, loaded_config) = if let Some(parent) = path.parent() { + if parent.extension().and_then(|s| s.to_str()) == Some("cap") { + let meta = RecordingMeta::load_for_project(parent).ok(); + let config = ProjectConfiguration::load(parent).ok(); + (meta, config) + } else { + (None, None) + } + } else { + (None, None) + }; + + let recording_meta = if let Some(meta) = recording_meta { + meta + } else { + // Create dummy meta + let filename = path + .file_name() + .ok_or_else(|| "Invalid path".to_string())? + .to_string_lossy(); + let relative_path = RelativePathBuf::from(filename.as_ref()); + let video_meta = VideoMeta { + path: relative_path.clone(), + fps: 30, + start_time: Some(0.0), + }; + let segment = SingleSegment { + display: video_meta.clone(), + camera: None, + audio: None, + cursor: None, + }; + let studio_meta = StudioRecordingMeta::SingleSegment { segment }; + RecordingMeta { + platform: None, + project_path: path.parent().unwrap().to_path_buf(), + pretty_name: "Screenshot".to_string(), + sharing: None, + inner: RecordingMetaInner::Studio(studio_meta.clone()), + upload: None, + } + }; + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + }) + .await + .map_err(|_| "No GPU adapter found".to_string())?; + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("cap-rendering-device"), + required_features: wgpu::Features::empty(), + ..Default::default() + }) + .await + .map_err(|e| e.to_string())?; + + let options = cap_rendering::RenderOptions { + screen_size: cap_project::XY::new(width, height), + camera_size: None, + }; + + // We need to extract the studio meta from the recording meta + let studio_meta = match &recording_meta.inner { + RecordingMetaInner::Studio(meta) => meta.clone(), + _ => return Err("Invalid recording meta for screenshot".to_string()), + }; + + let constants = RenderVideoConstants { + _instance: instance, + _adapter: adapter, + queue, + device, + options, + meta: studio_meta, + recording_meta: recording_meta.clone(), + background_textures: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + }; + + let (config_tx, mut config_rx) = watch::channel(loaded_config.unwrap_or_default()); + + let instance = Arc::new(ScreenshotEditorInstance { + ws_port, + ws_shutdown_token, + config_tx, + path: path.clone(), + }); + + // Spawn render loop + let decoded_frame = DecodedFrame::new(data, width, height); + + tokio::spawn(async move { + let mut frame_renderer = FrameRenderer::new(&constants); + let mut layers = RendererLayers::new(&constants.device, &constants.queue); + + // Initial render + println!("Initial screenshot render"); + let mut current_config = config_rx.borrow().clone(); + + loop { + println!( + "Rendering screenshot frame with config: {:?}", + current_config + ); + let segment_frames = DecodedSegmentFrames { + screen_frame: DecodedFrame::new( + decoded_frame.data().to_vec(), + decoded_frame.width(), + decoded_frame.height(), + ), + camera_frame: None, + segment_time: 0.0, + recording_time: 0.0, + }; + + let (base_w, base_h) = + ProjectUniforms::get_base_size(&constants.options, ¤t_config); + + let uniforms = ProjectUniforms::new( + &constants, + ¤t_config, + 0, + 30, + cap_project::XY::new(base_w, base_h), + &cap_project::CursorEvents::default(), + &segment_frames, + ); + + let rendered_frame = frame_renderer + .render( + segment_frames, + uniforms, + &cap_project::CursorEvents::default(), + &mut layers, + ) + .await; + + match rendered_frame { + Ok(frame) => { + println!( + "Frame rendered successfully: {}x{}", + frame.width, frame.height + ); + let _ = frame_tx.send(Some(WSFrame { + data: frame.data, + width: frame.width, + height: frame.height, + stride: frame.padded_bytes_per_row, + })); + } + Err(e) => { + eprintln!("Failed to render frame: {e}"); + } + } + + // Wait for config change + println!("Waiting for config change"); + if config_rx.changed().await.is_err() { + println!("Config channel closed"); + break; + } + current_config = config_rx.borrow().clone(); + println!("Config changed"); + } + }); + + entry.insert(instance.clone()); + Ok(instance) + } + Entry::Occupied(entry) => { + let instance = entry.get().clone(); + // Force a re-render for the new client by sending the current config again + let config = instance.config_tx.borrow().clone(); + let _ = instance.config_tx.send(config); + Ok(instance) + } + } + } + + pub async fn remove(window: Window) { + let instances = match window.try_state::() { + Some(s) => (*s).clone(), + None => return, + }; + + let mut instances = instances.0.write().await; + if let Some(instance) = instances.remove(window.label()) { + instance.dispose().await; + } + } +} + +#[derive(Serialize, Type, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SerializedScreenshotEditorInstance { + pub frames_socket_url: String, + pub path: PathBuf, + pub config: Option, +} + +#[tauri::command] +#[specta::specta] +pub async fn create_screenshot_editor_instance( + window: Window, +) -> Result { + let CapWindowId::ScreenshotEditor { id } = + CapWindowId::from_str(window.label()).map_err(|e| e.to_string())? + else { + return Err("Invalid window".to_string()); + }; + + let path = { + let window_ids = ScreenshotEditorWindowIds::get(window.app_handle()); + let window_ids = window_ids.ids.lock().unwrap(); + let Some((path, _)) = window_ids.iter().find(|(_, _id)| *_id == id) else { + return Err("Screenshot editor instance not found".to_string()); + }; + path.clone() + }; + + let instance = ScreenshotEditorInstances::get_or_create(&window, path).await?; + let config = instance.config_tx.borrow().clone(); + + Ok(SerializedScreenshotEditorInstance { + frames_socket_url: format!("ws://localhost:{}", instance.ws_port), + path: instance.path.clone(), + config: Some(config), + }) +} + +#[tauri::command] +#[specta::specta] +pub async fn update_screenshot_config( + instance: WindowScreenshotEditorInstance, + config: ProjectConfiguration, + save: bool, +) -> Result<(), String> { + let _ = instance.config_tx.send(config.clone()); + + if save { + if let Some(parent) = instance.path.parent() { + if parent.extension().and_then(|s| s.to_str()) == Some("cap") { + let path = parent.to_path_buf(); + if let Err(e) = config.write(&path) { + eprintln!("Failed to save screenshot config: {}", e); + } else { + println!("Saved screenshot config to {:?}", path); + } + } else { + println!( + "Not saving config: parent {:?} is not a .cap directory", + parent + ); + } + } + } + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 12cde9fef2..37cc8f10b1 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -49,6 +49,7 @@ pub enum CapWindowId { Upgrade, ModeSelect, Debug, + ScreenshotEditor { id: u32 }, } impl FromStr for CapWindowId { @@ -73,6 +74,12 @@ impl FromStr for CapWindowId { .parse::() .map_err(|e| e.to_string())?, }, + s if s.starts_with("screenshot-editor-") => Self::ScreenshotEditor { + id: s + .replace("screenshot-editor-", "") + .parse::() + .map_err(|e| e.to_string())?, + }, s if s.starts_with("window-capture-occluder-") => Self::WindowCaptureOccluder { screen_id: s .replace("window-capture-occluder-", "") @@ -110,6 +117,7 @@ impl std::fmt::Display for CapWindowId { Self::ModeSelect => write!(f, "mode-select"), Self::Editor { id } => write!(f, "editor-{id}"), Self::Debug => write!(f, "debug"), + Self::ScreenshotEditor { id } => write!(f, "screenshot-editor-{id}"), } } } @@ -127,6 +135,7 @@ impl CapWindowId { Self::CaptureArea => "Cap Capture Area".to_string(), Self::RecordingControls => "Cap Recording Controls".to_string(), Self::Editor { .. } => "Cap Editor".to_string(), + Self::ScreenshotEditor { .. } => "Cap Screenshot Editor".to_string(), Self::ModeSelect => "Cap Mode Selection".to_string(), Self::Camera => "Cap Camera".to_string(), Self::RecordingsOverlay => "Cap Recordings Overlay".to_string(), @@ -140,6 +149,7 @@ impl CapWindowId { Self::Setup | Self::Main | Self::Editor { .. } + | Self::ScreenshotEditor { .. } | Self::Settings | Self::Upgrade | Self::ModeSelect @@ -154,7 +164,9 @@ impl CapWindowId { #[cfg(target_os = "macos")] pub fn traffic_lights_position(&self) -> Option>> { match self { - Self::Editor { .. } => Some(Some(LogicalPosition::new(20.0, 32.0))), + Self::Editor { .. } | Self::ScreenshotEditor { .. } => { + Some(Some(LogicalPosition::new(20.0, 32.0))) + } Self::RecordingControls => Some(Some(LogicalPosition::new(-100.0, -100.0))), Self::Camera | Self::WindowCaptureOccluder { .. } @@ -170,6 +182,7 @@ impl CapWindowId { Self::Setup => (600.0, 600.0), Self::Main => (300.0, 360.0), Self::Editor { .. } => (1275.0, 800.0), + Self::ScreenshotEditor { .. } => (800.0, 600.0), Self::Settings => (600.0, 450.0), Self::Camera => (200.0, 200.0), Self::Upgrade => (950.0, 850.0), @@ -207,6 +220,9 @@ pub enum ShowCapWindow { }, Upgrade, ModeSelect, + ScreenshotEditor { + path: PathBuf, + }, } impl ShowCapWindow { @@ -224,6 +240,19 @@ impl ShowCapWindow { } } + if let Self::ScreenshotEditor { path } = &self { + let state = app.state::(); + let mut s = state.ids.lock().unwrap(); + if !s.iter().any(|(p, _)| p == path) { + s.push(( + path.clone(), + state + .counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst), + )); + } + } + if let Some(window) = self.id(app).get(app) { window.show().ok(); window.unminimize().ok(); @@ -414,6 +443,17 @@ impl ShowCapWindow { .center() .build()? } + Self::ScreenshotEditor { path: _ } => { + if let Some(main) = CapWindowId::Main.get(app) { + let _ = main.close(); + }; + + self.window_builder(app, "/screenshot-editor") + .maximizable(true) + .inner_size(1240.0, 800.0) + .center() + .build()? + } Self::Upgrade => { // Hide main window when upgrade window opens if let Some(main) = CapWindowId::Main.get(app) { @@ -803,6 +843,12 @@ impl ShowCapWindow { ShowCapWindow::InProgressRecording { .. } => CapWindowId::RecordingControls, ShowCapWindow::Upgrade => CapWindowId::Upgrade, ShowCapWindow::ModeSelect => CapWindowId::ModeSelect, + ShowCapWindow::ScreenshotEditor { path } => { + let state = app.state::(); + let s = state.ids.lock().unwrap(); + let id = s.iter().find(|(p, _)| p == path).unwrap().1; + CapWindowId::ScreenshotEditor { id } + } } } } @@ -1008,3 +1054,15 @@ impl EditorWindowIds { app.state::().deref().clone() } } + +#[derive(Default, Clone)] +pub struct ScreenshotEditorWindowIds { + pub ids: Arc>>, + pub counter: Arc, +} + +impl ScreenshotEditorWindowIds { + pub fn get(app: &AppHandle) -> Self { + app.state::().deref().clone() + } +} diff --git a/apps/desktop/src/components/Mode.tsx b/apps/desktop/src/components/Mode.tsx index 17a2494f11..37acdc2590 100644 --- a/apps/desktop/src/components/Mode.tsx +++ b/apps/desktop/src/components/Mode.tsx @@ -63,6 +63,28 @@ const Mode = () => { )} + {!isInfoHovered() && ( + +
{ + setOptions({ mode: "screenshot" }); + }} + class={`flex justify-center items-center transition-all duration-200 rounded-full size-7 hover:cursor-pointer ${ + rawOptions.mode === "screenshot" + ? "ring-2 ring-offset-1 ring-offset-gray-1 bg-gray-7 hover:bg-gray-7 ring-blue-500" + : "bg-gray-3 hover:bg-gray-7" + }`} + > + +
+
+ )} + {isInfoHovered() && ( <>
{ >
+ +
{ + setOptions({ mode: "screenshot" }); + }} + class={`flex justify-center items-center transition-all duration-200 rounded-full size-7 hover:cursor-pointer ${ + rawOptions.mode === "screenshot" + ? "ring-2 ring-offset-1 ring-offset-gray-1 bg-gray-5 hover:bg-gray-7 ring-blue-10" + : "bg-gray-3 hover:bg-gray-7" + }`} + > + +
)} diff --git a/apps/desktop/src/components/ModeSelect.tsx b/apps/desktop/src/components/ModeSelect.tsx index 5c95359016..61f1d17a6f 100644 --- a/apps/desktop/src/components/ModeSelect.tsx +++ b/apps/desktop/src/components/ModeSelect.tsx @@ -68,13 +68,20 @@ const ModeSelect = (props: { onClose?: () => void; standalone?: boolean }) => { "Records at the highest quality and framerate, completely locally. Captures both your screen and camera separately for editing and exporting later.", icon: IconCapFilmCut, }, + { + mode: "screenshot" as const, + title: "Screenshot Mode", + description: + "Capture high-quality screenshots of your screen or specific windows. Annotate and share instantly.", + icon: IconCapScreenshot, + }, ]; return (
{ +interface Props extends ComponentProps { content?: JSX.Element; childClass?: string; kbd?: string[]; @@ -23,8 +23,8 @@ const kbdSymbolModifier = (key: string, os: Os) => { export default function Tooltip(props: Props) { const os = ostype(); return ( - - + + {props.children} @@ -42,6 +42,6 @@ export default function Tooltip(props: Props) { - + ); } diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index 23e41f6f2b..c1e51068be 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -278,7 +278,7 @@ function Page() { - Previous Recordings}> + Recordings}> + + +
+ ); diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx index abb75d55f2..565d8a3452 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx @@ -5,7 +5,11 @@ import type { CaptureDisplayWithThumbnail, CaptureWindowWithThumbnail, } from "~/utils/tauri"; -import TargetCard, { TargetCardSkeleton } from "./TargetCard"; +import TargetCard, { + type RecordingWithPath, + type ScreenshotWithPath, + TargetCardSkeleton, +} from "./TargetCard"; const DEFAULT_SKELETON_COUNT = 6; @@ -29,7 +33,19 @@ type WindowGridProps = BaseProps & { variant: "window"; }; -type TargetMenuGridProps = DisplayGridProps | WindowGridProps; +type RecordingGridProps = BaseProps & { + variant: "recording"; +}; + +type ScreenshotGridProps = BaseProps & { + variant: "screenshot"; +}; + +type TargetMenuGridProps = + | DisplayGridProps + | WindowGridProps + | RecordingGridProps + | ScreenshotGridProps; export default function TargetMenuGrid(props: TargetMenuGridProps) { const items = createMemo(() => props.targets ?? []); @@ -97,7 +113,13 @@ export default function TargetMenuGrid(props: TargetMenuGridProps) { }; const defaultEmptyMessage = () => - props.variant === "display" ? "No displays found" : "No windows found"; + props.variant === "display" + ? "No displays found" + : props.variant === "window" + ? "No windows found" + : props.variant === "recording" + ? "No recordings found" + : "No screenshots found"; return (
+ + {(() => { + const recordingProps = props as RecordingGridProps; + return ( + + {(item, index) => ( + +
+ recordingProps.onSelect?.(item)} + disabled={recordingProps.disabled} + onKeyDown={handleKeyDown} + class="w-full" + data-target-menu-card="true" + highlightQuery={recordingProps.highlightQuery} + /> +
+
+ )} +
+ ); + })()} +
+ + {(() => { + const screenshotProps = props as ScreenshotGridProps; + return ( + + {(item, index) => ( + +
+ screenshotProps.onSelect?.(item)} + disabled={screenshotProps.disabled} + onKeyDown={handleKeyDown} + class="w-full" + data-target-menu-card="true" + highlightQuery={screenshotProps.highlightQuery} + /> +
+
+ )} +
+ ); + })()} +
diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 82c008f325..eef73ecaba 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -1,7 +1,7 @@ import { Button } from "@cap/ui-solid"; import { createEventListener } from "@solid-primitives/event-listener"; import { useNavigate } from "@solidjs/router"; -import { createMutation, useQuery } from "@tanstack/solid-query"; +import { createMutation, queryOptions, useQuery } from "@tanstack/solid-query"; import { listen } from "@tauri-apps/api/event"; import { getAllWebviewWindows, @@ -14,7 +14,7 @@ import { } from "@tauri-apps/api/window"; import * as dialog from "@tauri-apps/plugin-dialog"; import { type as ostype } from "@tauri-apps/plugin-os"; -import * as updater from "@tauri-apps/plugin-updater"; +import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; import { createEffect, @@ -40,6 +40,7 @@ import { createLicenseQuery, listAudioDevices, listDisplaysWithThumbnails, + listRecordings, listScreens, listVideoDevices, listWindows, @@ -53,12 +54,19 @@ import { type CaptureWindowWithThumbnail, commands, type DeviceOrModelID, + type RecordingMetaWithMetadata, type RecordingTargetMode, type ScreenCaptureTarget, } from "~/utils/tauri"; +import IconCapLogoFull from "~icons/cap/logo-full"; +import IconCapLogoFullDark from "~icons/cap/logo-full-dark"; +import IconCapSettings from "~icons/cap/settings"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; import IconLucideArrowLeft from "~icons/lucide/arrow-left"; +import IconLucideBug from "~icons/lucide/bug"; +import IconLucideImage from "~icons/lucide/image"; import IconLucideSearch from "~icons/lucide/search"; +import IconLucideSquarePlay from "~icons/lucide/square-play"; import IconMaterialSymbolsScreenshotFrame2Rounded from "~icons/material-symbols/screenshot-frame-2-rounded"; import IconMdiMonitor from "~icons/mdi/monitor"; import { WindowChromeHeader } from "../Context"; @@ -70,6 +78,7 @@ import CameraSelect from "./CameraSelect"; import ChangelogButton from "./ChangeLogButton"; import MicrophoneSelect from "./MicrophoneSelect"; import SystemAudio from "./SystemAudio"; +import type { RecordingWithPath, ScreenshotWithPath } from "./TargetCard"; import TargetDropdownButton from "./TargetDropdownButton"; import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; @@ -139,6 +148,18 @@ type TargetMenuPanelProps = variant: "window"; targets?: CaptureWindowWithThumbnail[]; onSelect: (target: CaptureWindowWithThumbnail) => void; + } + | { + variant: "recording"; + targets?: RecordingWithPath[]; + onSelect: (target: RecordingWithPath) => void; + onViewAll: () => void; + } + | { + variant: "screenshot"; + targets?: ScreenshotWithPath[]; + onSelect: (target: ScreenshotWithPath) => void; + onViewAll: () => void; }; type SharedTargetMenuProps = { @@ -153,11 +174,21 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { const trimmedSearch = createMemo(() => search().trim()); const normalizedQuery = createMemo(() => trimmedSearch().toLowerCase()); const placeholder = - props.variant === "display" ? "Search displays" : "Search windows"; + props.variant === "display" + ? "Search displays" + : props.variant === "window" + ? "Search windows" + : props.variant === "recording" + ? "Search recordings" + : "Search screenshots"; const noResultsMessage = props.variant === "display" ? "No matching displays" - : "No matching windows"; + : props.variant === "window" + ? "No matching windows" + : props.variant === "recording" + ? "No matching recordings" + : "No matching screenshots"; const filteredDisplayTargets = createMemo( () => { @@ -192,6 +223,30 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { ); }); + const filteredRecordingTargets = createMemo(() => { + if (props.variant !== "recording") return []; + const query = normalizedQuery(); + const targets = props.targets ?? []; + if (!query) return targets; + + const matchesQuery = (value?: string | null) => + !!value && value.toLowerCase().includes(query); + + return targets.filter((target) => matchesQuery(target.pretty_name)); + }); + + const filteredScreenshotTargets = createMemo(() => { + if (props.variant !== "screenshot") return []; + const query = normalizedQuery(); + const targets = props.targets ?? []; + if (!query) return targets; + + const matchesQuery = (value?: string | null) => + !!value && value.toLowerCase().includes(query); + + return targets.filter((target) => matchesQuery(target.pretty_name)); + }); + return (
@@ -205,25 +260,51 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { Back
- - setSearch(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Escape" && search()) { - event.preventDefault(); - setSearch(""); - } - }} - placeholder={placeholder} - autoCapitalize="off" - autocorrect="off" - autocomplete="off" - spellcheck={false} - aria-label={placeholder} - /> + + + setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape" && search()) { + event.preventDefault(); + setSearch(""); + } + }} + placeholder={placeholder} + autoCapitalize="off" + autocorrect="off" + autocomplete="off" + spellcheck={false} + aria-label={placeholder} + /> + + } + > + +
@@ -239,7 +320,7 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { highlightQuery={trimmedSearch()} emptyMessage={trimmedSearch() ? noResultsMessage : undefined} /> - ) : ( + ) : props.variant === "window" ? ( + ) : props.variant === "recording" ? ( + + ) : ( + )}
+ + {/* Removed sticky footer button */} +
); @@ -326,9 +434,15 @@ function Page() { const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); - const activeMenu = createMemo<"display" | "window" | null>(() => { + const [recordingsMenuOpen, setRecordingsMenuOpen] = createSignal(false); + const [screenshotsMenuOpen, setScreenshotsMenuOpen] = createSignal(false); + const activeMenu = createMemo< + "display" | "window" | "recording" | "screenshot" | null + >(() => { if (displayMenuOpen()) return "display"; if (windowMenuOpen()) return "window"; + if (recordingsMenuOpen()) return "recording"; + if (screenshotsMenuOpen()) return "screenshot"; return null; }); const [hasOpenedDisplayMenu, setHasOpenedDisplayMenu] = createSignal(false); @@ -347,6 +461,21 @@ function Page() { refetchInterval: false, })); + const recordings = useQuery(() => listRecordings); + const screenshots = useQuery(() => + queryOptions({ + queryKey: ["screenshots"], + queryFn: async () => { + const result = await commands + .listScreenshots() + .catch(() => [] as const); + + return result.map(([path, meta]) => ({ ...meta, path })); + }, + refetchInterval: 2000, + }), + ); + const screens = useQuery(() => listScreens); const windows = useQuery(() => listWindows); @@ -379,6 +508,24 @@ function Page() { return windowTargets.data?.filter((target) => ids.has(target.id)); }); + const recordingsData = createMemo(() => { + const data = recordings.data; + if (!data) return []; + // The Rust backend sorts files descending by creation time (newest first). + // See list_recordings in apps/desktop/src-tauri/src/lib.rs + // b_time.cmp(&a_time) ensures newest first. + // So we just need to take the top 20. + return data + .slice(0, 20) + .map(([path, meta]) => ({ ...meta, path }) as RecordingWithPath); + }); + + const screenshotsData = createMemo(() => { + const data = screenshots.data; + if (!data) return []; + return data.slice(0, 20) as ScreenshotWithPath[]; + }); + const displayMenuLoading = () => !hasDisplayTargetsData() && (displayTargets.status === "pending" || @@ -430,6 +577,8 @@ function Page() { if (!isRecording()) return; setDisplayMenuOpen(false); setWindowMenuOpen(false); + setRecordingsMenuOpen(false); + setScreenshotsMenuOpen(false); }); createUpdateCheck(); @@ -801,20 +950,44 @@ function Page() { - Previous Recordings}> + Recordings}> + Screenshots}> + + {import.meta.env.DEV && ( - + + ); } + +function TooltipIconButton( + props: ParentProps<{ + onClick: () => void; + tooltipText: string; + disabled?: boolean; + }>, +) { + return ( + + { + e.stopPropagation(); + props.onClick(); + }} + disabled={props.disabled} + class="p-2.5 opacity-70 will-change-transform hover:opacity-100 rounded-full transition-all duration-200 hover:bg-gray-3 dark:hover:bg-gray-5 disabled:pointer-events-none disabled:opacity-45 disabled:hover:opacity-45" + > + {props.children} + + + + {props.tooltipText} + + + + ); +} diff --git a/apps/desktop/src/routes/editor/ui.tsx b/apps/desktop/src/routes/editor/ui.tsx index 83d8b4cafe..8601aa0d7e 100644 --- a/apps/desktop/src/routes/editor/ui.tsx +++ b/apps/desktop/src/routes/editor/ui.tsx @@ -65,9 +65,11 @@ export function Subfield( export function Slider( props: ComponentProps & { formatTooltip?: string | ((v: number) => string); + history?: { pause: () => () => void }; }, ) { - const { projectHistory: history } = useEditorContext(); + const context = useEditorContext(); + const history = props.history ?? context?.projectHistory; // Pause history when slider is being dragged let resumeHistory: (() => void) | null = null; @@ -86,7 +88,7 @@ export function Slider( props.class, )} onChange={(v) => { - if (!resumeHistory) resumeHistory = history.pause(); + if (!resumeHistory && history) resumeHistory = history.pause(); props.onChange?.(v); }} onChangeEnd={(e) => { diff --git a/apps/desktop/src/routes/mode-select.tsx b/apps/desktop/src/routes/mode-select.tsx index bbeb90846e..cbd8fe8d71 100644 --- a/apps/desktop/src/routes/mode-select.tsx +++ b/apps/desktop/src/routes/mode-select.tsx @@ -1,10 +1,22 @@ +import type { UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; -import { onMount } from "solid-js"; +import { type as ostype } from "@tauri-apps/plugin-os"; +import { onCleanup, onMount } from "solid-js"; import ModeSelect from "~/components/ModeSelect"; +import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import { initializeTitlebar } from "~/utils/titlebar-state"; const ModeSelectWindow = () => { + let unlistenResize: UnlistenFn | undefined; + const isWindows = ostype() === "windows"; + onMount(async () => { const window = getCurrentWindow(); + + if (isWindows) { + unlistenResize = await initializeTitlebar(); + } + try { const currentSize = await window.innerSize(); @@ -16,14 +28,23 @@ const ModeSelectWindow = () => { } }); + onCleanup(() => { + unlistenResize?.(); + }); + return (
+ {isWindows && ( +
+ +
+ )}

Recording Modes diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx new file mode 100644 index 0000000000..fe4ef91e6f --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx @@ -0,0 +1,261 @@ +import { Popover } from "@kobalte/core/popover"; +import { createMemo, Show } from "solid-js"; +import { Portal } from "solid-js/web"; +import { BACKGROUND_COLORS, hexToRgb, RgbInput, rgbToHex } from "./ColorPicker"; +import { type Annotation, useScreenshotEditorContext } from "./context"; +import { Slider } from "./ui"; + +export function AnnotationConfig() { + const { + annotations, + selectedAnnotationId, + setAnnotations, + projectHistory, + setSelectedAnnotationId, + } = useScreenshotEditorContext(); + + const selected = createMemo(() => + annotations.find((a) => a.id === selectedAnnotationId()), + ); + + const update = ( + field: K, + value: Annotation[K], + ) => { + projectHistory.push(); + setAnnotations((a) => a.id === selectedAnnotationId(), field, value); + }; + + return ( + + {(ann) => { + const type = ann().type; + const isMask = type === "mask"; + const maskType = () => ann().maskType ?? "blur"; + const maskLevel = () => ann().maskLevel ?? 16; + return ( + +
+ +
+ + {type === "text" ? "Color" : "Stroke"} + + update("strokeColor", c)} + /> +
+
+ + +
+ + Width {ann().strokeWidth}px + + update("strokeWidth", v[0])} + minValue={1} + maxValue={20} + step={1} + class="w-full" + /> +
+
+ + +
+ + Fill + + update("fillColor", c)} + allowTransparent + /> +
+
+ + +
+ + Opacity {Math.round(ann().opacity * 100)}% + + update("opacity", v[0])} + minValue={0.1} + maxValue={1} + step={0.1} + class="w-full" + /> +
+
+ + +
+ + Style + +
+ + +
+
+
+ + +
+ + Intensity {Math.round(maskLevel())} + + update("maskLevel", v[0])} + minValue={4} + maxValue={50} + step={1} + class="w-full" + /> +
+
+ + {/* Font Size for Text */} + +
+ + Size {ann().height}px + + update("height", v[0])} + minValue={12} + maxValue={100} + step={1} + class="w-full" + /> +
+
+ +
+ + +
+ + ); + }} + + ); +} + +function ColorPickerButton(props: { + value: string; + onChange: (value: string) => void; + allowTransparent?: boolean; +}) { + // Helper to handle RGB <-> Hex + const rgbValue = createMemo(() => { + if (props.value === "transparent") + return [0, 0, 0] as [number, number, number]; + const rgb = hexToRgb(props.value); + if (!rgb) return [0, 0, 0] as [number, number, number]; + return [rgb[0], rgb[1], rgb[2]] as [number, number, number]; + }); + + const isTransparent = createMemo(() => props.value === "transparent"); + + return ( + + +
+
+
+ + + +
+ { + props.onChange(rgbToHex(rgb)); + }} + /> + +
+ + + + c !== "#00000000")}> + {(color) => ( + + )} + +
+
+
+
+ + ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx new file mode 100644 index 0000000000..caf799c7d0 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx @@ -0,0 +1,658 @@ +import { cx } from "cva"; +import { + createEffect, + createMemo, + createSignal, + For, + onCleanup, + Show, +} from "solid-js"; +import { unwrap } from "solid-js/store"; +import { + type Annotation, + type AnnotationType, + type ScreenshotProject, + useScreenshotEditorContext, +} from "./context"; + +export function AnnotationLayer(props: { + bounds: { x: number; y: number; width: number; height: number }; + cssWidth: number; + cssHeight: number; +}) { + const { + project, + annotations, + setAnnotations, + activeTool, + setActiveTool, + selectedAnnotationId, + setSelectedAnnotationId, + projectHistory, + } = useScreenshotEditorContext(); + + const [isDrawing, setIsDrawing] = createSignal(false); + const [dragState, setDragState] = createSignal<{ + id: string; + action: "move" | "resize"; + handle?: string; + startX: number; + startY: number; + original: Annotation; + } | null>(null); + + // History snapshots + let dragSnapshot: { + project: ScreenshotProject; + annotations: Annotation[]; + } | null = null; + let drawSnapshot: { + project: ScreenshotProject; + annotations: Annotation[]; + } | null = null; + let textSnapshot: { + project: ScreenshotProject; + annotations: Annotation[]; + } | null = null; + + const [textEditingId, setTextEditingId] = createSignal(null); + + // Temporary annotation being drawn + const [tempAnnotation, setTempAnnotation] = createSignal( + null, + ); + + // Delete key handler + createEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (textEditingId()) return; + if (e.key === "Backspace" || e.key === "Delete") { + const id = selectedAnnotationId(); + if (id) { + projectHistory.push(); // Save current state before delete + setAnnotations((prev) => prev.filter((a) => a.id !== id)); + setSelectedAnnotationId(null); + } + } + }; + window.addEventListener("keydown", handleKeyDown); + onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); + }); + + // Helper to get coordinates in SVG space + const getSvgPoint = ( + e: MouseEvent, + svg: SVGSVGElement, + ): { x: number; y: number } => { + const rect = svg.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + // Scale to viewBox + return { + x: props.bounds.x + (x / rect.width) * props.bounds.width, + y: props.bounds.y + (y / rect.height) * props.bounds.height, + }; + }; + + const handleMouseDown = (e: MouseEvent) => { + // If editing text, click outside commits change (handled by blur on input usually, but safety here) + if (textEditingId()) { + // If clicking inside the text editor, don't stop + if ((e.target as HTMLElement).closest(".text-editor")) return; + setTextEditingId(null); + } + + if (activeTool() === "select") { + if (e.target === e.currentTarget) { + setSelectedAnnotationId(null); + } + return; + } + + // Snapshot for drawing + drawSnapshot = { + project: structuredClone(unwrap(project)), + annotations: structuredClone(unwrap(annotations)), + }; + + const svg = e.currentTarget as SVGSVGElement; + const point = getSvgPoint(e, svg); + + setIsDrawing(true); + const id = crypto.randomUUID(); + const newAnn: Annotation = { + id, + type: activeTool() as AnnotationType, + x: point.x, + y: point.y, + width: 0, + height: 0, + strokeColor: "#F05656", // Red default + strokeWidth: 4, + fillColor: "transparent", + opacity: 1, + rotation: 0, + text: activeTool() === "text" ? "Text" : null, + maskType: activeTool() === "mask" ? "blur" : null, + maskLevel: activeTool() === "mask" ? 16 : null, + }; + + if (activeTool() === "text") { + newAnn.height = 40; // Default font size + newAnn.width = 150; // Default width + } + + setTempAnnotation(newAnn); + }; + + const handleMouseMove = (e: MouseEvent) => { + const svg = e.currentTarget as SVGSVGElement; + const point = getSvgPoint(e, svg); + + if (isDrawing() && tempAnnotation()) { + const temp = tempAnnotation()!; + // Update temp annotation dimensions + if (temp.type === "text") return; + + let width = point.x - temp.x; + let height = point.y - temp.y; + + // Shift key for aspect ratio constraint + if (e.shiftKey) { + if ( + temp.type === "rectangle" || + temp.type === "circle" || + temp.type === "mask" + ) { + const size = Math.max(Math.abs(width), Math.abs(height)); + width = width < 0 ? -size : size; + height = height < 0 ? -size : size; + } else if (temp.type === "arrow") { + // Snap to 45 degree increments + const angle = Math.atan2(height, width); + const snap = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4); + const dist = Math.sqrt(width * width + height * height); + width = Math.cos(snap) * dist; + height = Math.sin(snap) * dist; + } + } + + setTempAnnotation({ + ...temp, + width, + height, + }); + return; + } + + if (dragState()) { + const state = dragState()!; + const dx = point.x - state.startX; + const dy = point.y - state.startY; + + if (state.action === "move") { + setAnnotations( + (a) => a.id === state.id, + (a) => ({ + ...a, + x: state.original.x + dx, + y: state.original.y + dy, + }), + ); + } else if (state.action === "resize" && state.handle) { + const original = state.original; + let newX = original.x; + let newY = original.y; + let newW = original.width; + let newH = original.height; + + // For arrow: 'start' and 'end' handles + if (original.type === "arrow") { + if (state.handle === "start") { + newX = original.x + dx; + newY = original.y + dy; + newW = original.width - dx; + newH = original.height - dy; + } else if (state.handle === "end") { + newW = original.width + dx; + newH = original.height + dy; + } + } else { + // For shapes + if (state.handle.includes("e")) newW = original.width + dx; + if (state.handle.includes("s")) newH = original.height + dy; + if (state.handle.includes("w")) { + newX = original.x + dx; + newW = original.width - dx; + } + if (state.handle.includes("n")) { + newY = original.y + dy; + newH = original.height - dy; + } + + // Shift constraint during resize + if ( + e.shiftKey && + (original.type === "rectangle" || original.type === "circle") + ) { + // This is complex for corner resizing, simplifying: + // Just force aspect ratio based on original + const ratio = original.width / original.height; + if (state.handle.includes("e") || state.handle.includes("w")) { + // Width driven, adjust height + // This is tricky with 8 handles. Skipping proper aspect resize for now to save time/complexity + // Or simple implementation: + } + } + } + + setAnnotations((a) => a.id === state.id, { + x: newX, + y: newY, + width: newW, + height: newH, + }); + } + } + }; + + const handleMouseUp = () => { + if (isDrawing() && tempAnnotation()) { + const ann = tempAnnotation()!; + // Normalize rect/circle negative width/height + if ( + ann.type === "rectangle" || + ann.type === "circle" || + ann.type === "mask" + ) { + if (ann.width < 0) { + ann.x += ann.width; + ann.width = Math.abs(ann.width); + } + if (ann.height < 0) { + ann.y += ann.height; + ann.height = Math.abs(ann.height); + } + if (ann.width < 5 && ann.height < 5) { + setTempAnnotation(null); + setIsDrawing(false); + drawSnapshot = null; // Cancel snapshot if too small + return; + } + } + // For arrow, we keep negative width/height as vector + + // Commit history + if (drawSnapshot) projectHistory.push(drawSnapshot); + drawSnapshot = null; + + setAnnotations((prev) => [...prev, ann]); + setTempAnnotation(null); + setIsDrawing(false); + setActiveTool("select"); + setSelectedAnnotationId(ann.id); + } + + if (dragState()) { + // Commit history if changed + // We can check if current annotations differ from snapshot, but that's expensive. + // Instead, we assume if we dragged, we changed. + // We need to know if we actually moved. + // But we don't have "current" vs "original" easily without checking. + // Simpler: always push if dragSnapshot exists. + if (dragSnapshot) { + projectHistory.push(dragSnapshot); + } + dragSnapshot = null; + } + + setDragState(null); + }; + + const startDrag = (e: MouseEvent, id: string, handle?: string) => { + e.stopPropagation(); + if (activeTool() !== "select") return; + + const svg = (e.currentTarget as Element).closest("svg")!; + const point = getSvgPoint(e, svg); + const annotation = annotations.find((a) => a.id === id); + + if (annotation) { + // Snapshot for dragging + dragSnapshot = { + project: structuredClone(unwrap(project)), + annotations: structuredClone(unwrap(annotations)), + }; + + setSelectedAnnotationId(id); + setDragState({ + id, + action: handle ? "resize" : "move", + handle, + startX: point.x, + startY: point.y, + original: { ...annotation }, + }); + } + }; + + const handleDoubleClick = (e: MouseEvent, id: string) => { + e.stopPropagation(); + const ann = annotations.find((a) => a.id === id); + if (ann && ann.type === "text") { + // Snapshot for text editing + textSnapshot = { + project: structuredClone(unwrap(project)), + annotations: structuredClone(unwrap(annotations)), + }; + setTextEditingId(id); + } + }; + + const handleSize = createMemo(() => { + if (props.cssWidth === 0) return 0; + return (10 / props.cssWidth) * props.bounds.width; + }); + + return ( + + + + + + + + + {(ann) => ( + startDrag(e, ann.id)} + onDblClick={(e) => handleDoubleClick(e, ann.id)} + class="group" + style={{ + "pointer-events": "all", + cursor: activeTool() === "select" ? "move" : "inherit", + }} + > + {/* Text Editor Overlay */} + + +
{ + setTimeout(() => { + el.focus(); + // Select all text + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + }); + }} + onBlur={(e) => { + const text = e.currentTarget.innerText; + const originalText = annotations.find( + (a) => a.id === ann.id, + )?.text; + + if (!text.trim()) { + // If deleting, use snapshot + if (textSnapshot) projectHistory.push(textSnapshot); + setAnnotations((prev) => + prev.filter((a) => a.id !== ann.id), + ); + } else if (text !== originalText) { + // If changed, use snapshot + if (textSnapshot) projectHistory.push(textSnapshot); + setAnnotations((a) => a.id === ann.id, "text", text); + } + + textSnapshot = null; + setTextEditingId(null); + }} + onKeyDown={(e) => { + e.stopPropagation(); // Prevent deleting annotation + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + e.currentTarget.blur(); + } + }} + > + {ann.text} +
+
+
+ + + + + + + + +
+ )} +
+ + {(ann) => } + +
+ ); +} + +function RenderAnnotation(props: { annotation: Annotation }) { + return ( + <> + {props.annotation.type === "rectangle" && ( + + )} + {props.annotation.type === "circle" && ( + + )} + {props.annotation.type === "arrow" && ( + + )} + {props.annotation.type === "text" && ( + + {props.annotation.text} + + )} + {props.annotation.type === "mask" && ( + + )} + + ); +} + +function SelectionHandles(props: { + annotation: Annotation; + handleSize: number; + onResizeStart: (e: MouseEvent, id: string, handle: string) => void; +}) { + const half = createMemo(() => props.handleSize / 2); + + return ( + + + {(handle) => ( + + props.onResizeStart(e, props.annotation.id, handle.id) + } + /> + )} + + + } + > + + + props.onResizeStart(e, props.annotation.id, "start") + } + /> + + props.onResizeStart(e, props.annotation.id, "end") + } + /> + + + ); +} + +function Handle(props: { + x: number; + y: number; + size: number; + cursor: string; + onMouseDown: (e: MouseEvent) => void; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx new file mode 100644 index 0000000000..61b47a4c4e --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx @@ -0,0 +1,65 @@ +import { cx } from "cva"; +import { Show } from "solid-js"; +import IconLucideArrowUpRight from "~icons/lucide/arrow-up-right"; +import IconLucideCircle from "~icons/lucide/circle"; +import IconLucideEyeOff from "~icons/lucide/eye-off"; +import IconLucideMousePointer2 from "~icons/lucide/mouse-pointer-2"; +import IconLucideSquare from "~icons/lucide/square"; +import IconLucideType from "~icons/lucide/type"; +import { AnnotationConfig } from "./AnnotationConfig"; +import { type AnnotationType, useScreenshotEditorContext } from "./context"; + +export function AnnotationTools() { + return ( + <> +
+ + + + + + +
+ + + ); +} + +import type { Component } from "solid-js"; + +function ToolButton(props: { + tool: AnnotationType | "select"; + icon: Component<{ class?: string }>; + label: string; +}) { + const { activeTool, setActiveTool, setSelectedAnnotationId } = + useScreenshotEditorContext(); + return ( + + ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/ColorPicker.tsx b/apps/desktop/src/routes/screenshot-editor/ColorPicker.tsx new file mode 100644 index 0000000000..8b50c2d7ee --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/ColorPicker.tsx @@ -0,0 +1,107 @@ +import { createWritableMemo } from "@solid-primitives/memo"; +import { TextInput } from "./TextInput"; + +export const BACKGROUND_COLORS = [ + "#FF0000", // Red + "#FF4500", // Orange-Red + "#FF8C00", // Orange + "#FFD700", // Gold + "#FFFF00", // Yellow + "#ADFF2F", // Green-Yellow + "#32CD32", // Lime Green + "#008000", // Green + "#00CED1", // Dark Turquoise + "#4785FF", // Dodger Blue + "#0000FF", // Blue + "#4B0082", // Indigo + "#800080", // Purple + "#A9A9A9", // Dark Gray + "#FFFFFF", // White + "#000000", // Black + "#00000000", // Transparent +]; + +export function RgbInput(props: { + value: [number, number, number]; + onChange: (value: [number, number, number]) => void; +}) { + const [text, setText] = createWritableMemo(() => rgbToHex(props.value)); + let prevHex = rgbToHex(props.value); + let colorInput!: HTMLInputElement; + + return ( +
+
+ ); +} + +export function rgbToHex(rgb: [number, number, number]) { + return `#${rgb + .map((c) => c.toString(16).padStart(2, "0")) + .join("") + .toUpperCase()}`; +} + +export function hexToRgb(hex: string): [number, number, number, number] | null { + const match = hex.match( + /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i, + ); + if (!match) return null; + const [, r, g, b, a] = match; + const rgb = [ + Number.parseInt(r, 16), + Number.parseInt(g, 16), + Number.parseInt(b, 16), + ] as const; + if (a) { + return [...rgb, Number.parseInt(a, 16)]; + } + return [...rgb, 255]; +} diff --git a/apps/desktop/src/routes/screenshot-editor/Editor.tsx b/apps/desktop/src/routes/screenshot-editor/Editor.tsx new file mode 100644 index 0000000000..59b303f6d5 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/Editor.tsx @@ -0,0 +1,379 @@ +import { Button } from "@cap/ui-solid"; +import { NumberField } from "@kobalte/core/number-field"; +import { makePersisted } from "@solid-primitives/storage"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { LogicalPosition } from "@tauri-apps/api/dpi"; +import { Menu } from "@tauri-apps/api/menu"; +import { + createEffect, + createSignal, + Match, + onCleanup, + Show, + Switch, +} from "solid-js"; +import { Transition } from "solid-transition-group"; +import { + CROP_ZERO, + type CropBounds, + Cropper, + type CropperRef, + createCropOptionsMenuItems, + type Ratio, +} from "~/components/Cropper"; +import { composeEventHandlers } from "~/utils/composeEventHandlers"; +import IconCapCircleX from "~icons/cap/circle-x"; +import IconLucideMaximize from "~icons/lucide/maximize"; +import IconLucideRatio from "~icons/lucide/ratio"; +import { useScreenshotEditorContext } from "./context"; +import { ExportDialog } from "./ExportDialog"; +import { Header } from "./Header"; +import { Preview } from "./Preview"; +import { Dialog, DialogContent, EditorButton, Input, Subfield } from "./ui"; + +export function Editor() { + const [zoom, setZoom] = createSignal(1); + const { + projectHistory, + setActiveTool, + setProject, + project, + setSelectedAnnotationId, + } = useScreenshotEditorContext(); + + createEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if typing in an input or contenteditable + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + const isMod = e.metaKey || e.ctrlKey; + const isShift = e.shiftKey; + + // Undo / Redo + if (isMod && e.key.toLowerCase() === "z") { + e.preventDefault(); + if (isShift) { + projectHistory.redo(); + } else { + projectHistory.undo(); + } + return; + } + if (isMod && e.key.toLowerCase() === "y") { + e.preventDefault(); + projectHistory.redo(); + return; + } + + // Tools (No modifiers) + if (!isMod && !isShift) { + switch (e.key.toLowerCase()) { + case "a": + setActiveTool("arrow"); + setSelectedAnnotationId(null); + break; + case "r": + setActiveTool("rectangle"); + setSelectedAnnotationId(null); + break; + case "c": + case "o": // Support 'o' for oval/circle too + setActiveTool("circle"); + setSelectedAnnotationId(null); + break; + case "t": + setActiveTool("text"); + setSelectedAnnotationId(null); + break; + case "v": + case "s": + case "escape": + setActiveTool("select"); + setSelectedAnnotationId(null); + break; + case "p": { + // Toggle Padding + // We need to push history here too if we want undo for padding + projectHistory.push(); + const currentPadding = project.background.padding; + setProject("background", "padding", currentPadding === 0 ? 20 : 0); + break; + } + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); + }); + + return ( + <> +
+
+
+
+ +
+
+ +
+ + ); +} + +function Dialogs() { + const { dialog, setDialog, project, setProject, path } = + useScreenshotEditorContext(); + + return ( + { + const d = dialog(); + if ("type" in d && d.type === "crop") return "lg"; + return "sm"; + })()} + contentClass={(() => { + const d = dialog(); + // if ("type" in d && d.type === "export") return "max-w-[740px]"; + return ""; + })()} + open={dialog().open} + onOpenChange={(o) => { + if (!o) setDialog((d) => ({ ...d, open: false })); + }} + > + { + const d = dialog(); + if ("type" in d) return d; + })()} + > + {(dialog) => ( + + + + + { + const d = dialog(); + if (d.type === "crop") return d; + })()} + > + {(dialog) => { + let cropperRef: CropperRef | undefined; + const [crop, setCrop] = createSignal(CROP_ZERO); + const [aspect, setAspect] = createSignal(null); + + const initialBounds = { + x: dialog().position.x, + y: dialog().position.y, + width: dialog().size.x, + height: dialog().size.y, + }; + + const [snapToRatio, setSnapToRatioEnabled] = makePersisted( + createSignal(true), + { name: "editorCropSnapToRatio" }, + ); + + async function showCropOptionsMenu( + e: UIEvent, + positionAtCursor = false, + ) { + e.preventDefault(); + const items = createCropOptionsMenuItems({ + aspect: aspect(), + snapToRatioEnabled: snapToRatio(), + onAspectSet: setAspect, + onSnapToRatioSet: setSnapToRatioEnabled, + }); + const menu = await Menu.new({ items }); + let pos: LogicalPosition | undefined; + if (!positionAtCursor) { + const rect = ( + e.target as HTMLDivElement + ).getBoundingClientRect(); + pos = new LogicalPosition(rect.x, rect.y + 40); + } + await menu.popup(pos); + await menu.close(); + } + + function BoundInput(props: { + field: keyof CropBounds; + min?: number; + max?: number; + }) { + return ( + { + cropperRef?.setCropProperty(props.field, v); + }} + changeOnWheel={true} + format={false} + > + ([ + (e) => e.stopPropagation(), + ])} + /> + + ); + } + + return ( + <> + +
+
+ Size +
+ +
+ × +
+ +
+
+
+ Position +
+ +
+ × +
+ +
+
+
+
+
+ + + + } + onClick={() => cropperRef?.fill()} + disabled={ + crop().width === dialog().size.x && + crop().height === dialog().size.y + } + > + Full + + } + onClick={() => { + cropperRef?.reset(); + setAspect(null); + }} + disabled={ + crop().x === dialog().position.x && + crop().y === dialog().position.y && + crop().width === dialog().size.x && + crop().height === dialog().size.y + } + > + Reset + +
+
+ +
+
+ showCropOptionsMenu(e, true)} + > + screenshot + +
+
+
+ + + + + ); + }} +
+
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx b/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx new file mode 100644 index 0000000000..bc20446730 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx @@ -0,0 +1,42 @@ +import { Button } from "@cap/ui-solid"; +import IconCapCopy from "~icons/cap/copy"; +import IconCapFile from "~icons/cap/file"; +import { DialogContent } from "./ui"; +import { useScreenshotExport } from "./useScreenshotExport"; + +export function ExportDialog() { + const { exportImage, isExporting } = useScreenshotExport(); + + return ( + +
+

+ Choose where to export your screenshot. +

+
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx new file mode 100644 index 0000000000..4b2db39bde --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -0,0 +1,170 @@ +import { Button } from "@cap/ui-solid"; +import { DropdownMenu } from "@kobalte/core/dropdown-menu"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { ask } from "@tauri-apps/plugin-dialog"; +import { remove } from "@tauri-apps/plugin-fs"; +import { revealItemInDir } from "@tauri-apps/plugin-opener"; +import { type as ostype } from "@tauri-apps/plugin-os"; +import { cx } from "cva"; +import { createEffect, createSignal, Suspense } from "solid-js"; +import Tooltip from "~/components/Tooltip"; +import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import { commands } from "~/utils/tauri"; +import IconCapCrop from "~icons/cap/crop"; +import IconCapTrash from "~icons/cap/trash"; +import IconCapZoomIn from "~icons/cap/zoom-in"; +import IconCapZoomOut from "~icons/cap/zoom-out"; +import IconLucideCopy from "~icons/lucide/copy"; +import IconLucideFolder from "~icons/lucide/folder"; +import IconLucideMoreHorizontal from "~icons/lucide/more-horizontal"; +import IconLucideSave from "~icons/lucide/save"; +import { AnnotationTools } from "./AnnotationTools"; +import { useScreenshotEditorContext } from "./context"; +import PresetsSubMenu from "./PresetsDropdown"; +import { useScreenshotExport } from "./useScreenshotExport"; +import { AspectRatioSelect } from "./popovers/AspectRatioSelect"; +import { BackgroundSettingsPopover } from "./popovers/BackgroundSettingsPopover"; +import { BorderPopover } from "./popovers/BorderPopover"; +import { PaddingPopover } from "./popovers/PaddingPopover"; +import { RoundingPopover } from "./popovers/RoundingPopover"; +import { ShadowPopover } from "./popovers/ShadowPopover"; +import { + DropdownItem, + EditorButton, + MenuItemList, + PopperContent, + Slider, + topSlideAnimateClasses, +} from "./ui"; + +export function Header() { + const { path, setDialog, project, latestFrame } = + useScreenshotEditorContext(); + + const { exportImage, isExporting } = useScreenshotExport(); + + const cropDialogHandler = () => { + const frame = latestFrame(); + setDialog({ + open: true, + type: "crop", + position: { + ...(project.background.crop?.position ?? { x: 0, y: 0 }), + }, + size: { + ...(project.background.crop?.size ?? { + x: frame?.width ?? 0, + y: frame?.data.height ?? 0, + }), + }, + }); + }; + + return ( +
+
+ {ostype() === "macos" &&
} +
+ +
+ + } + /> +
+ +
+ + + + + +
+ +
+
+ + { + exportImage("clipboard"); + }} + tooltipText="Copy to Clipboard" + disabled={isExporting()} + leftIcon={} + /> + + setDialog({ type: "export", open: true })} + leftIcon={} + /> + + + + as={DropdownMenu.Trigger} + tooltipText="More Actions" + leftIcon={} + /> + + + + as={DropdownMenu.Content} + class={cx("min-w-[200px]", topSlideAnimateClasses)} + > + + as={DropdownMenu.Group} + class="p-1" + > + { + revealItemInDir(path); + }} + > + + Open Folder + + { + if ( + await ask( + "Are you sure you want to delete this screenshot?", + ) + ) { + await remove(path); + await getCurrentWindow().close(); + } + }} + > + + Delete + + + + + + + as={DropdownMenu.Group} + class="p-1" + > + + + + + + + + {ostype() === "windows" && } +
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx b/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx new file mode 100644 index 0000000000..b4cf0ae267 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx @@ -0,0 +1,57 @@ +import { DropdownMenu as KDropdownMenu } from "@kobalte/core/dropdown-menu"; +import { cx } from "cva"; +import { Suspense } from "solid-js"; +import IconCapCirclePlus from "~icons/cap/circle-plus"; +import IconCapPresets from "~icons/cap/presets"; +import IconLucideChevronRight from "~icons/lucide/chevron-right"; +import { + DropdownItem, + MenuItemList, + PopperContent, + topSlideAnimateClasses, +} from "./ui"; + +export function PresetsSubMenu() { + return ( + + +
+ + Presets +
+ +
+ + + + as={KDropdownMenu.SubContent} + class={cx("w-72 max-h-56", topSlideAnimateClasses)} + > + + as={KDropdownMenu.Group} + class="overflow-y-auto flex-1 scrollbar-none" + > +
+ No Presets +
+ + + as={KDropdownMenu.Group} + class="border-t shrink-0" + > + + Create new preset + + + + +
+
+
+ ); +} + +export default PresetsSubMenu; diff --git a/apps/desktop/src/routes/screenshot-editor/Preview.tsx b/apps/desktop/src/routes/screenshot-editor/Preview.tsx new file mode 100644 index 0000000000..f8086e3af0 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/Preview.tsx @@ -0,0 +1,422 @@ +import { createElementBounds } from "@solid-primitives/bounds"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { cx } from "cva"; +import { createEffect, createMemo, createSignal, Show } from "solid-js"; +import Tooltip from "~/components/Tooltip"; +import IconCapCrop from "~icons/cap/crop"; +import IconCapZoomIn from "~icons/cap/zoom-in"; +import IconCapZoomOut from "~icons/cap/zoom-out"; +import { ASPECT_RATIOS } from "../editor/projectConfig"; +import { EditorButton, Slider } from "../editor/ui"; +import { useScreenshotEditorContext } from "./context"; +import { AspectRatioSelect } from "./popovers/AspectRatioSelect"; + +// CSS for checkerboard grid +const gridStyle = { + "background-color": "white", + "background-image": + "linear-gradient(45deg, #f0f0f0 25%, transparent 25%), linear-gradient(-45deg, #f0f0f0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f0f0f0 75%), linear-gradient(-45deg, transparent 75%, #f0f0f0 75%)", + "background-size": "20px 20px", + "background-position": "0 0, 0 10px, 10px -10px, -10px 0px", +}; + +import { AnnotationLayer } from "./AnnotationLayer"; + +export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { + const { path, project, setDialog, latestFrame, annotations, activeTool } = + useScreenshotEditorContext(); + let canvasRef: HTMLCanvasElement | undefined; + + const [canvasContainerRef, setCanvasContainerRef] = + createSignal(); + const containerBounds = createElementBounds(canvasContainerRef); + + const [pan, setPan] = createSignal({ x: 0, y: 0 }); + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + if (e.ctrlKey) { + // Zoom + const delta = -e.deltaY; + const zoomStep = 0.005; + const newZoom = Math.max(0.1, Math.min(3, props.zoom + delta * zoomStep)); + props.setZoom(newZoom); + } else { + // Pan + setPan((p) => ({ + x: p.x - e.deltaX, + y: p.y - e.deltaY, + })); + } + }; + + createEffect(() => { + const frame = latestFrame(); + if (frame && canvasRef) { + const ctx = canvasRef.getContext("2d"); + if (ctx) { + ctx.putImageData(frame.data, 0, 0); + } + } + }); + + return ( +
+ {/* Preview Area */} +
+
+ props.setZoom(Math.max(0.1, props.zoom - 0.1))} + > + + + props.setZoom(v)} + formatTooltip={(v) => `${Math.round(v * 100)}%`} + /> + props.setZoom(Math.min(3, props.zoom + 0.1))} + > + + +
+ Loading preview...
} + > + {(_) => { + const padding = 20; + const frame = () => { + const f = latestFrame(); + if (!f) + return { + width: 0, + data: { width: 0, height: 0 } as ImageData, + }; + return f; + }; + + const frameWidth = () => frame().width; + const frameHeight = () => frame().data.height; + + const bounds = createMemo(() => { + const crop = project.background.crop; + let minX = crop ? crop.position.x : 0; + let minY = crop ? crop.position.y : 0; + let maxX = crop ? crop.position.x + crop.size.x : frameWidth(); + let maxY = crop ? crop.position.y + crop.size.y : frameHeight(); + + for (const ann of annotations) { + const ax1 = ann.x; + const ay1 = ann.y; + const ax2 = ann.x + ann.width; + const ay2 = ann.y + ann.height; + + const left = Math.min(ax1, ax2); + const right = Math.max(ax1, ax2); + const top = Math.min(ay1, ay2); + const bottom = Math.max(ay1, ay2); + + minX = Math.min(minX, left); + maxX = Math.max(maxX, right); + minY = Math.min(minY, top); + maxY = Math.max(maxY, bottom); + } + + let x = minX; + let y = minY; + let width = maxX - minX; + let height = maxY - minY; + + if (project.aspectRatio) { + const ratioConf = ASPECT_RATIOS[project.aspectRatio]; + if (ratioConf) { + const targetRatio = ratioConf.ratio[0] / ratioConf.ratio[1]; + const currentRatio = width / height; + + if (currentRatio > targetRatio) { + const newHeight = width / targetRatio; + const padY = (newHeight - height) / 2; + y -= padY; + height = newHeight; + } else { + const newWidth = height * targetRatio; + const padX = (newWidth - width) / 2; + x -= padX; + width = newWidth; + } + } + } + + return { + x, + y, + width, + height, + }; + }); + + const availableWidth = () => + Math.max((containerBounds.width ?? 0) - padding * 2, 0); + const availableHeight = () => + Math.max((containerBounds.height ?? 0) - padding * 2, 0); + + const containerAspect = () => { + const width = availableWidth(); + const height = availableHeight(); + if (width === 0 || height === 0) return 1; + return width / height; + }; + + const contentAspect = () => { + const width = bounds().width; + const height = bounds().height; + if (width === 0 || height === 0) return containerAspect(); + return width / height; + }; + + const size = () => { + let width: number; + let height: number; + if (contentAspect() < containerAspect()) { + height = availableHeight(); + width = height * contentAspect(); + } else { + width = availableWidth(); + height = width / contentAspect(); + } + + return { + width: Math.min(width, bounds().width), + height: Math.min(height, bounds().height), + }; + }; + + const fitScale = () => { + if (bounds().width === 0) return 1; + return size().width / bounds().width; + }; + + const cssScale = () => fitScale() * props.zoom; + const scaledWidth = () => frameWidth() * cssScale(); + const scaledHeight = () => frameHeight() * cssScale(); + const canvasLeft = () => -bounds().x * cssScale(); + const canvasTop = () => -bounds().y * cssScale(); + + let maskCanvasRef: HTMLCanvasElement | undefined; + + const blurRegion = ( + ctx: CanvasRenderingContext2D, + source: HTMLCanvasElement, + startX: number, + startY: number, + regionWidth: number, + regionHeight: number, + level: number, + ) => { + const scale = Math.max(2, Math.round(level / 4)); + const temp = document.createElement("canvas"); + temp.width = Math.max(1, Math.floor(regionWidth / scale)); + temp.height = Math.max(1, Math.floor(regionHeight / scale)); + const tempCtx = temp.getContext("2d"); + if (!tempCtx) return; + + tempCtx.imageSmoothingEnabled = true; + tempCtx.drawImage( + source, + startX, + startY, + regionWidth, + regionHeight, + 0, + 0, + temp.width, + temp.height, + ); + + ctx.drawImage( + temp, + 0, + 0, + temp.width, + temp.height, + startX, + startY, + regionWidth, + regionHeight, + ); + }; + + const renderMaskOverlays = () => { + const frameData = latestFrame(); + if (!maskCanvasRef) return; + const ctx = maskCanvasRef.getContext("2d"); + if (!ctx) return; + if (!frameData) { + maskCanvasRef.width = 0; + maskCanvasRef.height = 0; + return; + } + + const masks = annotations.filter((ann) => ann.type === "mask"); + + if ( + maskCanvasRef.width !== frameData.width || + maskCanvasRef.height !== frameData.data.height + ) { + maskCanvasRef.width = frameData.width; + maskCanvasRef.height = frameData.data.height; + } + + ctx.clearRect(0, 0, maskCanvasRef.width, maskCanvasRef.height); + + if (!masks.length || !canvasRef) return; + + const source = canvasRef; + + for (const mask of masks) { + const startX = Math.max( + 0, + Math.min(mask.x, mask.x + mask.width), + ); + const startY = Math.max( + 0, + Math.min(mask.y, mask.y + mask.height), + ); + const endX = Math.min( + frameData.width, + Math.max(mask.x, mask.x + mask.width), + ); + const endY = Math.min( + frameData.data.height, + Math.max(mask.y, mask.y + mask.height), + ); + + const regionWidth = endX - startX; + const regionHeight = endY - startY; + + if (regionWidth <= 0 || regionHeight <= 0) continue; + + const level = Math.max(1, mask.maskLevel ?? 16); + const type = mask.maskType ?? "blur"; + + if (type === "pixelate") { + const blockSize = Math.max(2, Math.round(level)); + const temp = document.createElement("canvas"); + temp.width = Math.max(1, Math.floor(regionWidth / blockSize)); + temp.height = Math.max( + 1, + Math.floor(regionHeight / blockSize), + ); + const tempCtx = temp.getContext("2d"); + if (!tempCtx) continue; + tempCtx.imageSmoothingEnabled = false; + tempCtx.drawImage( + source, + startX, + startY, + regionWidth, + regionHeight, + 0, + 0, + temp.width, + temp.height, + ); + ctx.imageSmoothingEnabled = false; + ctx.drawImage( + temp, + 0, + 0, + temp.width, + temp.height, + startX, + startY, + regionWidth, + regionHeight, + ); + ctx.imageSmoothingEnabled = true; + continue; + } + + blurRegion( + ctx, + source, + startX, + startY, + regionWidth, + regionHeight, + level, + ); + } + + ctx.filter = "none"; + }; + + createEffect(renderMaskOverlays); + + return ( +
+
+ + { + maskCanvasRef = el ?? maskCanvasRef; + renderMaskOverlays(); + }} + width={frameWidth()} + height={frameHeight()} + style={{ + position: "absolute", + left: `${canvasLeft()}px`, + top: `${canvasTop()}px`, + width: `${scaledWidth()}px`, + height: `${scaledHeight()}px`, + "pointer-events": "none", + }} + /> + +
+
+ ); + }} + +
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/TextInput.tsx b/apps/desktop/src/routes/screenshot-editor/TextInput.tsx new file mode 100644 index 0000000000..8fe37b95c0 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/TextInput.tsx @@ -0,0 +1,18 @@ +import type { ComponentProps } from "solid-js"; +import { composeEventHandlers } from "~/utils/composeEventHandlers"; + +// It's important to use this instead of plain text inputs as we use global key listeners +// for keybinds +export function TextInput(props: ComponentProps<"input">) { + return ( + ([ + props.onKeyDown, + (e) => { + e.stopPropagation(); + }, + ])} + /> + ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/context.tsx b/apps/desktop/src/routes/screenshot-editor/context.tsx new file mode 100644 index 0000000000..a2de8e9720 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/context.tsx @@ -0,0 +1,320 @@ +import { createContextProvider } from "@solid-primitives/context"; +import { trackStore } from "@solid-primitives/deep"; +import { debounce } from "@solid-primitives/scheduled"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { createEffect, createResource, createSignal, on } from "solid-js"; +import { createStore, reconcile, unwrap } from "solid-js/store"; +import { createImageDataWS, createLazySignal } from "~/utils/socket"; +import { + type Annotation, + type AnnotationType, + type AudioConfiguration, + type Camera, + type CursorConfiguration, + commands, + type HotkeysConfiguration, + type ProjectConfiguration, + type XY, +} from "~/utils/tauri"; + +export type ScreenshotProject = ProjectConfiguration; +export type { Annotation, AnnotationType }; + +export type CurrentDialog = + | { type: "createPreset" } + | { type: "renamePreset"; presetIndex: number } + | { type: "deletePreset"; presetIndex: number } + | { type: "crop"; position: XY; size: XY } + | { type: "export" }; + +export type DialogState = { open: false } | ({ open: boolean } & CurrentDialog); + +const DEFAULT_CAMERA: Camera = { + hide: false, + mirror: false, + position: { x: "right", y: "bottom" }, + size: 30, + zoom_size: 60, + rounding: 0, + shadow: 0, + advancedShadow: null, + shape: "square", + roundingType: "squircle", +} as unknown as Camera; + +const DEFAULT_AUDIO: AudioConfiguration = { + mute: false, + improve: false, + micVolumeDb: 0, + micStereoMode: "stereo", + systemVolumeDb: 0, +}; + +const DEFAULT_CURSOR: CursorConfiguration = { + hide: false, + hideWhenIdle: false, + hideWhenIdleDelay: 2, + size: 100, + type: "pointer", + animationStyle: "mellow", + tension: 120, + mass: 1.1, + friction: 18, + raw: false, + motionBlur: 0, + useSvg: true, +}; + +const DEFAULT_HOTKEYS: HotkeysConfiguration = { + show: false, +}; + +const DEFAULT_PROJECT: ScreenshotProject = { + background: { + source: { + type: "wallpaper", + path: "macOS/sequoia-dark", + }, + blur: 0, + padding: 20, + rounding: 10, + roundingType: "squircle", + inset: 0, + crop: null, + shadow: 0, + advancedShadow: null, + border: null, + }, + aspectRatio: null, + camera: DEFAULT_CAMERA, + audio: DEFAULT_AUDIO, + cursor: DEFAULT_CURSOR, + hotkeys: DEFAULT_HOTKEYS, + timeline: null, + captions: null, + clips: [], + annotations: [], +} as unknown as ScreenshotProject; + +function createScreenshotEditorContext() { + const [project, setProject] = createStore(DEFAULT_PROJECT); + const [annotations, setAnnotations] = createStore([]); + const [selectedAnnotationId, setSelectedAnnotationId] = createSignal< + string | null + >(null); + const [activeTool, setActiveTool] = createSignal( + "select", + ); + + const [dialog, setDialog] = createSignal({ + open: false, + }); + + const [latestFrame, setLatestFrame] = createLazySignal<{ + width: number; + data: ImageData; + }>(); + + const [editorInstance] = createResource(async () => { + const instance = await commands.createScreenshotEditorInstance(); + + if (instance.config) { + setProject(reconcile(instance.config)); + if (instance.config.annotations) { + setAnnotations(reconcile(instance.config.annotations)); + } + } + + // Load initial frame from disk in case WS fails or is slow + if (instance.path) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = convertFileSrc(instance.path); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage(img, 0, 0); + const data = ctx.getImageData( + 0, + 0, + img.naturalWidth, + img.naturalHeight, + ); + setLatestFrame({ width: img.naturalWidth, data }); + } + }; + } + + const [_ws, _isConnected] = createImageDataWS( + instance.framesSocketUrl, + setLatestFrame, + ); + + return instance; + }); + + const saveConfig = debounce((config: ProjectConfiguration) => { + commands.updateScreenshotConfig(config, true); + }, 1000); + + createEffect( + on( + [ + () => trackStore(project), + () => trackStore(annotations), + editorInstance, + ], + async ([, , instance]) => { + if (!instance) return; + + const config = { + ...unwrap(project), + annotations: unwrap(annotations), + }; + + commands.updateScreenshotConfig(config, false); + saveConfig(config); + }, + ), + ); + + // History Implementation + const [history, setHistory] = createStore<{ + past: { project: ScreenshotProject; annotations: Annotation[] }[]; + future: { project: ScreenshotProject; annotations: Annotation[] }[]; + }>({ + past: [], + future: [], + }); + + type HistorySnapshot = { + project: ScreenshotProject; + annotations: Annotation[]; + }; + + let pausedHistorySnapshot: HistorySnapshot | null = null; + let hasPausedHistoryChanges = false; + const [historyPauseCount, setHistoryPauseCount] = createSignal(0); + + createEffect( + on([() => trackStore(project), () => trackStore(annotations)], () => { + if (historyPauseCount() > 0) { + hasPausedHistoryChanges = true; + } + }), + ); + + const pushHistory = (snapshot: HistorySnapshot | null = null) => { + const state = snapshot ?? { + project: structuredClone(unwrap(project)), + annotations: structuredClone(unwrap(annotations)), + }; + setHistory("past", (p) => [...p, state]); + setHistory("future", []); + }; + + const pauseHistory = () => { + if (historyPauseCount() === 0) { + pausedHistorySnapshot = { + project: structuredClone(unwrap(project)), + annotations: structuredClone(unwrap(annotations)), + }; + hasPausedHistoryChanges = false; + } + + setHistoryPauseCount((count) => count + 1); + + let resumed = false; + + return () => { + if (resumed) return; + resumed = true; + + setHistoryPauseCount((count) => { + const next = Math.max(0, count - 1); + + if (next === 0) { + if (pausedHistorySnapshot && hasPausedHistoryChanges) { + pushHistory(pausedHistorySnapshot); + } + + pausedHistorySnapshot = null; + hasPausedHistoryChanges = false; + } + + return next; + }); + }; + }; + + const undo = () => { + if (history.past.length === 0) return; + const previous = history.past[history.past.length - 1]; + const current = { + project: structuredClone(unwrap(project)), + annotations: structuredClone(unwrap(annotations)), + }; + + setHistory("past", (p) => p.slice(0, -1)); + setHistory("future", (f) => [current, ...f]); + + setProject(reconcile(previous.project)); + setAnnotations(reconcile(previous.annotations)); + }; + + const redo = () => { + if (history.future.length === 0) return; + const next = history.future[0]; + const current = { + project: structuredClone(unwrap(project)), + annotations: structuredClone(unwrap(annotations)), + }; + + setHistory("future", (f) => f.slice(1)); + setHistory("past", (p) => [...p, current]); + + setProject(reconcile(next.project)); + setAnnotations(reconcile(next.annotations)); + }; + + const canUndo = () => history.past.length > 0; + const canRedo = () => history.future.length > 0; + + const projectHistory = { + push: pushHistory, + undo, + redo, + canUndo, + canRedo, + pause: pauseHistory, + isPaused: () => historyPauseCount() > 0, + }; + + return { + get path() { + return editorInstance()?.path ?? ""; + }, + project, + setProject, + annotations, + setAnnotations, + selectedAnnotationId, + setSelectedAnnotationId, + activeTool, + setActiveTool, + projectHistory, + dialog, + setDialog, + latestFrame, + editorInstance, + }; +} + +export const [ScreenshotEditorProvider, useScreenshotEditorContext] = + createContextProvider( + createScreenshotEditorContext, + null as unknown as ReturnType, + ); diff --git a/apps/desktop/src/routes/screenshot-editor/index.tsx b/apps/desktop/src/routes/screenshot-editor/index.tsx new file mode 100644 index 0000000000..f1d993c603 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/index.tsx @@ -0,0 +1,35 @@ +import { Effect, getCurrentWindow } from "@tauri-apps/api/window"; +import { type as ostype } from "@tauri-apps/plugin-os"; +import { cx } from "cva"; +import { createEffect } from "solid-js"; +import { generalSettingsStore } from "~/store"; +import { commands } from "~/utils/tauri"; +import { ScreenshotEditorProvider } from "./context"; +import { Editor } from "./Editor"; + +export default function ScreenshotEditorRoute() { + const generalSettings = generalSettingsStore.createQuery(); + + createEffect(() => { + const transparent = generalSettings.data?.windowTransparency ?? false; + commands.setWindowTransparent(transparent); + getCurrentWindow().setEffects({ + effects: transparent ? [Effect.HudWindow] : [], + }); + }); + + return ( +
+ + + +
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/AnnotationPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/AnnotationPopover.tsx new file mode 100644 index 0000000000..07b5c9c9e1 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/popovers/AnnotationPopover.tsx @@ -0,0 +1,209 @@ +import { Popover } from "@kobalte/core/popover"; +import { createMemo, For, Show } from "solid-js"; +import { Toggle } from "~/components/Toggle"; +import IconLucidePencil from "~icons/lucide/pencil"; +import IconLucideTrash from "~icons/lucide/trash-2"; +import { BACKGROUND_COLORS, hexToRgb, RgbInput } from "../ColorPicker"; +import { type Annotation, useScreenshotEditorContext } from "../context"; +import { EditorButton, Slider } from "../ui"; + +export function AnnotationPopover() { + const { + annotations, + setAnnotations, + selectedAnnotationId, + setSelectedAnnotationId, + } = useScreenshotEditorContext(); + + const selectedAnnotation = createMemo(() => + annotations.find((a) => a.id === selectedAnnotationId()), + ); + + const updateSelected = (key: keyof Annotation, value: any) => { + const id = selectedAnnotationId(); + if (!id) return; + setAnnotations((a) => a.id === id, key, value); + }; + + return ( + + } + tooltipText="Annotation Settings" + disabled={!selectedAnnotation()} + /> + + +
+ + Select an annotation to edit. +
+ } + > + {(annotation) => ( +
+
+ + Stroke Color + + + updateSelected( + "strokeColor", + `#${rgb + .map((c) => c.toString(16).padStart(2, "0")) + .join("") + .toUpperCase()}`, + ) + } + /> + {/* Color Presets */} +
+ + {(color) => ( +
+
+ + {(annotation().type === "rectangle" || + annotation().type === "circle") && ( +
+
+ + Fill Color + + + updateSelected( + "fillColor", + checked ? "#000000" : "transparent", + ) + } + /> +
+ + {annotation().fillColor !== "transparent" && ( + <> + + updateSelected( + "fillColor", + `#${rgb + .map((c) => c.toString(16).padStart(2, "0")) + .join("") + .toUpperCase()}`, + ) + } + /> +
+ + {(color) => ( +
+ + )} +
+ )} + +
+ + Stroke Width + + updateSelected("strokeWidth", v)} + minValue={1} + maxValue={20} + step={1} + /> +
+ +
+ + Opacity + + updateSelected("opacity", v / 100)} + minValue={0} + maxValue={100} + formatTooltip="%" + /> +
+ + {annotation().type === "text" && ( +
+ + Font Size + + updateSelected("height", v)} + minValue={12} + maxValue={100} + step={1} + /> +
+ )} + +
+ } + onClick={() => { + setAnnotations((prev) => + prev.filter((a) => a.id !== selectedAnnotationId()), + ); + setSelectedAnnotationId(null); + }} + > + Delete Annotation + +
+
+ )} + +
+ + + + ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/AspectRatioSelect.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/AspectRatioSelect.tsx new file mode 100644 index 0000000000..b218c21b2d --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/popovers/AspectRatioSelect.tsx @@ -0,0 +1,103 @@ +import { Select as KSelect } from "@kobalte/core/select"; +import { createSignal, Show } from "solid-js"; +import type { AspectRatio } from "~/utils/tauri"; +import IconCapChevronDown from "~icons/cap/chevron-down"; +import IconCapLayout from "~icons/cap/layout"; +import IconLucideCheckCircle from "~icons/lucide/check-circle-2"; +import { ASPECT_RATIOS } from "../../editor/projectConfig"; +import { useScreenshotEditorContext } from "../context"; +import { + EditorButton, + MenuItem, + MenuItemList, + PopperContent, + topLeftAnimateClasses, +} from "../ui"; + +export function AspectRatioSelect() { + const { project, setProject } = useScreenshotEditorContext(); + const [open, setOpen] = createSignal(false); + let triggerSelect: HTMLDivElement | undefined; + + return ( + + open={open()} + onOpenChange={setOpen} + ref={triggerSelect} + value={project.aspectRatio ?? "auto"} + onChange={(v) => { + if (v === null) return; + setProject("aspectRatio", v === "auto" ? null : v); + }} + defaultValue="auto" + options={ + ["auto", "wide", "vertical", "square", "classic", "tall"] as const + } + multiple={false} + itemComponent={(props) => { + const item = () => + props.item.rawValue === "auto" + ? null + : ASPECT_RATIOS[props.item.rawValue]; + + return ( + as={KSelect.Item} item={props.item}> + + {props.item.rawValue === "auto" + ? "Auto" + : ASPECT_RATIOS[props.item.rawValue].name} + + {(item) => ( + + {"â‹…"} + {item().ratio[0]}:{item().ratio[1]} + + )} + + + + + + + ); + }} + placement="bottom-start" + > + + as={KSelect.Trigger} + class="w-20" + tooltipText="Aspect Ratio" + leftIcon={} + rightIcon={ + + + + } + rightIconEnd={true} + > + > + {(state) => { + const text = () => { + const option = state.selectedOption(); + if (option === "auto") return "Auto"; + const ratio = ASPECT_RATIOS[option].ratio; + return `${ratio[0]}:${ratio[1]}`; + }; + return <>{text()}; + }} + + + + + as={KSelect.Content} + class={topLeftAnimateClasses} + > + + as={KSelect.Listbox} + class="w-[12.5rem]" + /> + + + + ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx new file mode 100644 index 0000000000..d999f4dee6 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx @@ -0,0 +1,532 @@ +import { Popover } from "@kobalte/core/popover"; +import { RadioGroup as KRadioGroup } from "@kobalte/core/radio-group"; +import { Tabs as KTabs } from "@kobalte/core/tabs"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { appDataDir, resolveResource } from "@tauri-apps/api/path"; +import { BaseDirectory, writeFile } from "@tauri-apps/plugin-fs"; +import { + batch, + createMemo, + createResource, + createSignal, + For, + Show, +} from "solid-js"; +import type { BackgroundSource } from "~/utils/tauri"; +import IconCapBgBlur from "~icons/cap/bg-blur"; +import IconCapCircleX from "~icons/cap/circle-x"; +import IconCapImage from "~icons/cap/image"; +import { + DEFAULT_GRADIENT_FROM, + DEFAULT_GRADIENT_TO, + type RGBColor, +} from "../../editor/projectConfig"; +import { BACKGROUND_COLORS, hexToRgb, RgbInput } from "../ColorPicker"; +import { useScreenshotEditorContext } from "../context"; +import { EditorButton, Field, Slider } from "../ui"; + +// Constants +const BACKGROUND_SOURCES = { + wallpaper: "Wallpaper", + image: "Image", + color: "Color", + gradient: "Gradient", +} satisfies Record; + +const BACKGROUND_SOURCES_LIST = [ + "wallpaper", + "image", + "color", + "gradient", +] satisfies Array; + +// Copied gradients +const BACKGROUND_GRADIENTS = [ + { from: [15, 52, 67], to: [52, 232, 158] }, + { from: [34, 193, 195], to: [253, 187, 45] }, + { from: [29, 253, 251], to: [195, 29, 253] }, + { from: [69, 104, 220], to: [176, 106, 179] }, + { from: [106, 130, 251], to: [252, 92, 125] }, + { from: [131, 58, 180], to: [253, 29, 29] }, + { from: [249, 212, 35], to: [255, 78, 80] }, + { from: [255, 94, 0], to: [255, 42, 104] }, + { from: [255, 0, 150], to: [0, 204, 255] }, + { from: [0, 242, 96], to: [5, 117, 230] }, + { from: [238, 205, 163], to: [239, 98, 159] }, + { from: [44, 62, 80], to: [52, 152, 219] }, + { from: [168, 239, 255], to: [238, 205, 163] }, + { from: [74, 0, 224], to: [143, 0, 255] }, + { from: [252, 74, 26], to: [247, 183, 51] }, + { from: [0, 255, 255], to: [255, 20, 147] }, + { from: [255, 127, 0], to: [255, 255, 0] }, + { from: [255, 0, 255], to: [0, 255, 0] }, +] satisfies Array<{ from: RGBColor; to: RGBColor }>; + +const WALLPAPER_NAMES = [ + "macOS/tahoe-dusk-min", + "macOS/tahoe-dawn-min", + "macOS/tahoe-day-min", + "macOS/tahoe-night-min", + "macOS/tahoe-dark", + "macOS/tahoe-light", + "macOS/sequoia-dark", + "macOS/sequoia-light", + "macOS/sonoma-clouds", + "macOS/sonoma-dark", + "macOS/sonoma-evening", + "macOS/sonoma-fromabove", + "macOS/sonoma-horizon", + "macOS/sonoma-light", + "macOS/sonoma-river", + "macOS/ventura-dark", + "macOS/ventura-semi-dark", + "macOS/ventura", + "blue/1", + "blue/2", + "blue/3", + "blue/4", + "blue/5", + "blue/6", + "purple/1", + "purple/2", + "purple/3", + "purple/4", + "purple/5", + "purple/6", + "dark/1", + "dark/2", + "dark/3", + "dark/4", + "dark/5", + "dark/6", + "orange/1", + "orange/2", + "orange/3", + "orange/4", + "orange/5", + "orange/6", + "orange/7", + "orange/8", + "orange/9", + "orange/10", +] as const; + +type WallpaperName = (typeof WALLPAPER_NAMES)[number]; + +const BACKGROUND_THEMES = { + macOS: "macOS", + dark: "Dark", + blue: "Blue", + purple: "Purple", + orange: "Orange", +}; + +export function BackgroundSettingsPopover() { + const { project, setProject, projectHistory } = useScreenshotEditorContext(); + + let scrollRef!: HTMLDivElement; + + // Background tabs + const [backgroundTab, setBackgroundTab] = + createSignal("macOS"); + + const [wallpapers] = createResource(async () => { + // Only load visible wallpapers initially + const visibleWallpaperPaths = WALLPAPER_NAMES.map(async (id) => { + try { + const path = await resolveResource(`assets/backgrounds/${id}.jpg`); + return { id, path }; + } catch { + return { id, path: null }; + } + }); + + // Load initial batch + const initialPaths = await Promise.all(visibleWallpaperPaths); + + return initialPaths + .filter((p): p is { id: WallpaperName; path: string } => p.path !== null) + .map(({ id, path }) => ({ + id, + url: convertFileSrc(path), + rawPath: path, + })); + }); + + const filteredWallpapers = createMemo(() => { + const currentTab = backgroundTab(); + return wallpapers()?.filter((wp) => wp.id.startsWith(currentTab)) || []; + }); + + let fileInput!: HTMLInputElement; + + const setProjectSource = (source: BackgroundSource) => { + setProject("background", "source", source); + }; + + // Debounced set project for history + const debouncedSetProject = (wallpaperPath: string) => { + const resumeHistory = projectHistory.pause(); + queueMicrotask(() => { + batch(() => { + setProject("background", "source", { + type: "wallpaper", + path: wallpaperPath, + } as const); + resumeHistory(); + }); + }); + }; + + const ensurePaddingForBackground = () => { + batch(() => { + const isPaddingZero = project.background.padding === 0; + const isRoundingZero = project.background.rounding === 0; + + if (isPaddingZero) { + setProject("background", "padding", 10); + } + + if (isPaddingZero && isRoundingZero) { + setProject("background", "rounding", 8); + } + }); + }; + + return ( + + } + tooltipText="Background Settings" + /> + + +
+ } + name="Background Image" + > + { + const tab = v as BackgroundSource["type"]; + let newSource: BackgroundSource; + switch (tab) { + case "wallpaper": + newSource = { type: "wallpaper", path: null }; + break; + case "image": + newSource = { type: "image", path: null }; + break; + case "color": + newSource = { + type: "color", + value: DEFAULT_GRADIENT_FROM, + }; + break; + case "gradient": + newSource = { + type: "gradient", + from: DEFAULT_GRADIENT_FROM, + to: DEFAULT_GRADIENT_TO, + }; + break; + } + + // Try to preserve existing if type matches + if (project.background.source.type === tab) { + newSource = project.background.source; + } + + setProjectSource(newSource); + if ( + tab === "wallpaper" || + tab === "image" || + tab === "gradient" + ) { + ensurePaddingForBackground(); + } + }} + > + + + {(item) => { + return ( + + {BACKGROUND_SOURCES[item]} + + ); + }} + + + +
+ + + + + + {([key, value]) => ( + + setBackgroundTab( + key as keyof typeof BACKGROUND_THEMES, + ) + } + value={key} + class="flex relative z-10 flex-1 justify-center items-center px-4 py-2 bg-transparent rounded-lg border transition-colors duration-200 text-gray-11 ui-not-selected:hover:border-gray-7 ui-selected:bg-gray-3 ui-selected:border-gray-3 group ui-selected:text-gray-12 disabled:opacity-50 focus:outline-none" + > + {value} + + )} + + + + + + ( + project.background.source as { path?: string } + ).path?.includes(w.id), + )?.url ?? undefined) + : undefined + } + onChange={(photoUrl) => { + const wallpaper = wallpapers()?.find( + (w) => w.url === photoUrl, + ); + if (wallpaper) { + debouncedSetProject(wallpaper.rawPath); + ensurePaddingForBackground(); + } + }} + class="grid grid-cols-7 gap-2 h-auto" + > + + {(photo) => ( + + + + Wallpaper option + + + )} + + + + + + fileInput.click()} + class="p-6 bg-gray-2 text-[13px] w-full rounded-[0.5rem] border border-gray-5 border-dashed flex flex-col items-center justify-center gap-[0.5rem] hover:bg-gray-3 transition-colors duration-100" + > + + + Click to select or drag and drop image + + + } + > + {(source) => ( +
+ Selected background +
+ +
+
+ )} +
+ { + const file = e.currentTarget.files?.[0]; + if (!file) return; + const fileName = `bg-${Date.now()}-${file.name}`; + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + const fullPath = `${await appDataDir()}/${fileName}`; + + await writeFile(fileName, uint8Array, { + baseDir: BaseDirectory.AppData, + }); + + setProjectSource({ + type: "image", + path: fullPath, + }); + ensurePaddingForBackground(); + }} + /> +
+ + +
+
+ + setProjectSource({ type: "color", value: v }) + } + /> +
+
+ + {(color) => ( +
+ + + + + {(source) => { + const angle = () => source().angle ?? 90; + return ( +
+
+ + setProjectSource({ ...source(), from }) + } + /> + + setProjectSource({ ...source(), to }) + } + /> +
+
+ + {(gradient) => ( +
+ ); + }} + + + + + + }> + setProject("background", "blur", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + +
+ + + + ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/BorderPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/BorderPopover.tsx new file mode 100644 index 0000000000..5cca1fdd41 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/popovers/BorderPopover.tsx @@ -0,0 +1,111 @@ +import { Collapsible } from "@kobalte/core/collapsible"; +import { Popover } from "@kobalte/core/popover"; +import { Toggle } from "~/components/Toggle"; +import IconCapEnlarge from "~icons/cap/enlarge"; +import IconCapImage from "~icons/cap/image"; +import IconCapSettings from "~icons/cap/settings"; +import IconCapShadow from "~icons/cap/shadow"; +import { RgbInput } from "../ColorPicker"; +import { useScreenshotEditorContext } from "../context"; +import { EditorButton, Field, Slider } from "../ui"; + +export function BorderPopover() { + const { project, setProject } = useScreenshotEditorContext(); + + return ( + + } + tooltipText="Border" + /> + + +
+
+ Border + { + const prev = project.background.border ?? { + enabled: false, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }; + setProject("background", "border", { + ...prev, + enabled, + }); + }} + /> +
+ + + +
+ }> + + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + width: v[0], + }) + } + minValue={1} + maxValue={20} + step={0.1} + formatTooltip="px" + /> + + }> + + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + color, + }) + } + /> + + }> + + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + opacity: v[0], + }) + } + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + +
+
+
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/PaddingPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/PaddingPopover.tsx new file mode 100644 index 0000000000..d4dbf4258d --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/popovers/PaddingPopover.tsx @@ -0,0 +1,33 @@ +import { Popover } from "@kobalte/core/popover"; +import IconCapPadding from "~icons/cap/padding"; +import { useScreenshotEditorContext } from "../context"; +import { EditorButton, Slider } from "../ui"; + +export function PaddingPopover() { + const { project, setProject } = useScreenshotEditorContext(); + + return ( + + } + tooltipText="Padding" + /> + + +
+ Padding + setProject("background", "padding", v[0])} + minValue={0} + maxValue={100} + step={1} + formatTooltip="px" + /> +
+
+
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/RoundingPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/RoundingPopover.tsx new file mode 100644 index 0000000000..c09a05e0b5 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/popovers/RoundingPopover.tsx @@ -0,0 +1,123 @@ +import { Popover } from "@kobalte/core/popover"; +import { Select as KSelect } from "@kobalte/core/select"; +import { cx } from "cva"; +import { Show, type ValidComponent } from "solid-js"; +import IconCapChevronDown from "~icons/cap/chevron-down"; +import IconCapCorners from "~icons/cap/corners"; +import { useScreenshotEditorContext } from "../context"; +import { + EditorButton, + MenuItem, + MenuItemList, + PopperContent, + Slider, + topSlideAnimateClasses, +} from "../ui"; + +export type CornerRoundingType = "rounded" | "squircle"; +const CORNER_STYLE_OPTIONS = [ + { name: "Squircle", value: "squircle" }, + { name: "Rounded", value: "rounded" }, +] satisfies Array<{ name: string; value: CornerRoundingType }>; + +export function RoundingPopover() { + const { project, setProject } = useScreenshotEditorContext(); + + return ( + + } + tooltipText="Corner Rounding" + /> + + +
+
+ Rounding + setProject("background", "rounding", v[0])} + minValue={0} + maxValue={100} + step={1} + formatTooltip="px" + /> +
+ setProject("background", "roundingType", v)} + /> +
+
+
+
+ ); +} + +function CornerStyleSelect(props: { + label?: string; + value: CornerRoundingType; + onChange: (value: CornerRoundingType) => void; +}) { + return ( +
+ + {(label) => ( + + {label()} + + )} + + + options={CORNER_STYLE_OPTIONS} + optionValue="value" + optionTextValue="name" + value={CORNER_STYLE_OPTIONS.find( + (option) => option.value === props.value, + )} + onChange={(option) => option && props.onChange(option.value)} + disallowEmptySelection + itemComponent={(itemProps) => ( + + as={KSelect.Item} + item={itemProps.item} + > + + {itemProps.item.rawValue.name} + + + )} + > + + class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> + {(state) => {state.selectedOption().name}} + + + as={(iconProps) => ( + + )} + /> + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-32" + as={KSelect.Listbox} + /> + + + +
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/ShadowPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/ShadowPopover.tsx new file mode 100644 index 0000000000..7372e9fdb8 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/popovers/ShadowPopover.tsx @@ -0,0 +1,96 @@ +import { Popover } from "@kobalte/core/popover"; +import { batch } from "solid-js"; +import IconCapShadow from "~icons/cap/shadow"; +import { useScreenshotEditorContext } from "../context"; +import { EditorButton, Slider } from "../ui"; +import ShadowSettings from "./ShadowSettings"; + +export function ShadowPopover() { + const { project, setProject } = useScreenshotEditorContext(); + // We need a dummy scrollRef since ShadowSettings expects it, + // but in this simple popover we might not need auto-scroll. + // Passing undefined might break it if it relies on it, checking ShadowSettings source would be good. + // Assuming it's optional or we can pass a dummy one. + let scrollRef: HTMLDivElement | undefined; + + return ( + + } + tooltipText="Shadow" + /> + + +
+
+ Shadow + { + batch(() => { + setProject("background", "shadow", v[0]); + if (v[0] > 0 && !project.background.advancedShadow) { + setProject("background", "advancedShadow", { + size: 50, + opacity: 18, + blur: 50, + }); + } + }); + }} + minValue={0} + maxValue={100} + step={1} + formatTooltip="%" + /> +
+ + { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + size: v[0], + }); + }, + }} + opacity={{ + value: [project.background.advancedShadow?.opacity ?? 18], + onChange: (v) => { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + opacity: v[0], + }); + }, + }} + blur={{ + value: [project.background.advancedShadow?.blur ?? 50], + onChange: (v) => { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + blur: v[0], + }); + }, + }} + /> +
+
+
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/ShadowSettings.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/ShadowSettings.tsx new file mode 100644 index 0000000000..e2eca87f22 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/popovers/ShadowSettings.tsx @@ -0,0 +1,90 @@ +import { Collapsible as KCollapsible } from "@kobalte/core/collapsible"; +import { cx } from "cva"; +import { createSignal } from "solid-js"; +import IconCapChevronDown from "~icons/cap/chevron-down"; +import { Field, Slider } from "../ui"; + +interface Props { + size: { + value: number[]; + onChange: (v: number[]) => void; + }; + opacity: { + value: number[]; + onChange: (v: number[]) => void; + }; + blur: { + value: number[]; + onChange: (v: number[]) => void; + }; + scrollRef?: HTMLDivElement; +} + +const ShadowSettings = (props: Props) => { + const [isOpen, setIsOpen] = createSignal(false); + + const handleToggle = () => { + setIsOpen(!isOpen()); + setTimeout(() => { + if (props.scrollRef) { + props.scrollRef.scrollTo({ + top: props.scrollRef.scrollHeight, + behavior: "smooth", + }); + } + }, 200); + }; + + return ( +
+ + + +
+ + + + + + + + + +
+
+
+
+ ); +}; + +export default ShadowSettings; diff --git a/apps/desktop/src/routes/screenshot-editor/ui.tsx b/apps/desktop/src/routes/screenshot-editor/ui.tsx new file mode 100644 index 0000000000..1f35bba049 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/ui.tsx @@ -0,0 +1,453 @@ +import { Button } from "@cap/ui-solid"; +import { Dialog as KDialog } from "@kobalte/core/dialog"; +import { DropdownMenu } from "@kobalte/core/dropdown-menu"; +import { Polymorphic, type PolymorphicProps } from "@kobalte/core/polymorphic"; +import { Slider as KSlider } from "@kobalte/core/slider"; +import { Tooltip as KTooltip } from "@kobalte/core/tooltip"; +import { createElementBounds } from "@solid-primitives/bounds"; +import { createEventListener } from "@solid-primitives/event-listener"; +import { cva, cx, type VariantProps } from "cva"; + +import { + type ComponentProps, + createRoot, + createSignal, + type JSX, + mergeProps, + type ParentProps, + splitProps, + type ValidComponent, +} from "solid-js"; +import Tooltip from "~/components/Tooltip"; +import { useScreenshotEditorContext } from "./context"; +import { TextInput } from "./TextInput"; + +export function Field( + props: ParentProps<{ + name: string; + icon?: JSX.Element; + value?: JSX.Element; + class?: string; + disabled?: boolean; + }>, +) { + return ( +
+ + {props.icon} + {props.name} + {props.value &&
{props.value}
} +
+ {props.children} +
+ ); +} + +export function Subfield( + props: ParentProps<{ name: string; class?: string; required?: boolean }>, +) { + return ( +
+ + {props.name} + {props.required && ( + * + )} + + {props.children} +
+ ); +} + +export function Slider( + props: ComponentProps & { + formatTooltip?: string | ((v: number) => string); + }, +) { + const { projectHistory: history } = useScreenshotEditorContext(); + + // Pause history when slider is being dragged + let resumeHistory: (() => void) | null = null; + + const [thumbRef, setThumbRef] = createSignal(); + + const thumbBounds = createElementBounds(thumbRef); + + const [dragging, setDragging] = createSignal(false); + + return ( + { + if (!resumeHistory) resumeHistory = history.pause(); + props.onChange?.(v); + }} + onChangeEnd={(e) => { + resumeHistory?.(); + resumeHistory = null; + props.onChangeEnd?.(e); + }} + > + { + setDragging(true); + createRoot((dispose) => { + createEventListener(window, "mouseup", () => { + setDragging(false); + dispose(); + }); + }); + }} + > + + { + return { + x: thumbBounds.left ?? undefined, + y: thumbBounds.top ?? undefined, + width: thumbBounds.width ?? undefined, + height: thumbBounds.height ?? undefined, + }; + }} + content={ + props.value?.[0] !== undefined + ? typeof props.formatTooltip === "string" + ? `${props.value[0].toFixed(1)}${props.formatTooltip}` + : props.formatTooltip + ? props.formatTooltip(props.value[0]) + : props.value[0].toFixed(1) + : undefined + } + > + { + setDragging(true); + }} + onPointerUp={() => { + setDragging(false); + }} + class={cx( + "bg-gray-1 dark:bg-gray-12 border border-gray-6 shadow-md rounded-full outline-none size-4 -top-[6.3px] ui-disabled:bg-gray-9 after:content-[''] after:absolute after:inset-0 after:-m-3 after:cursor-pointer", + )} + /> + + + + ); +} + +export function Input(props: ComponentProps<"input">) { + return ( + + ); +} + +export const Dialog = { + Root( + props: ComponentProps & { + hideOverlay?: boolean; + size?: "sm" | "lg"; + contentClass?: string; + }, + ) { + return ( + + + {!props.hideOverlay && ( + + )} +
+ + {props.children} + +
+
+
+ ); + }, + CloseButton() { + return ( + + Cancel + + ); + }, + ConfirmButton(_props: ComponentProps) { + const props = mergeProps( + { variant: "primary" } as ComponentProps, + _props, + ); + return