From f7047a0a1e66b6d31c6adcf530d1167e81bc7522 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 02:29:18 +0000 Subject: [PATCH 01/44] feat: v1 of screenshots --- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/src/recording.rs | 48 + apps/desktop/src-tauri/src/windows.rs | 29 +- apps/desktop/src/components/Mode.tsx | 35 + apps/desktop/src/components/ModeSelect.tsx | 7 + .../screenshot-editor/AspectRatioSelect.tsx | 105 ++ .../screenshot-editor/ConfigSidebar.tsx | 979 ++++++++++++++++++ .../src/routes/screenshot-editor/Editor.tsx | 292 ++++++ .../routes/screenshot-editor/ExportDialog.tsx | 126 +++ .../src/routes/screenshot-editor/Header.tsx | 174 ++++ .../screenshot-editor/PresetsDropdown.tsx | 55 + .../src/routes/screenshot-editor/Preview.tsx | 183 ++++ .../screenshot-editor/ShadowSettings.tsx | 90 ++ .../routes/screenshot-editor/TextInput.tsx | 18 + .../src/routes/screenshot-editor/context.tsx | 70 ++ .../src/routes/screenshot-editor/index.tsx | 49 + .../src/routes/screenshot-editor/ui.tsx | 451 ++++++++ .../src/routes/target-select-overlay.tsx | 103 +- apps/desktop/src/utils/tauri.ts | 7 +- crates/recording/Cargo.toml | 2 +- crates/recording/src/lib.rs | 4 +- crates/recording/src/screenshot.rs | 237 +++++ 22 files changed, 3027 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/routes/screenshot-editor/AspectRatioSelect.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/ConfigSidebar.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/Editor.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/Header.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/Preview.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/ShadowSettings.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/TextInput.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/context.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/index.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/ui.tsx create mode 100644 crates/recording/src/screenshot.rs diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e3c3f1ffa0..5dc049ef21 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2223,6 +2223,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, diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 1c821f87b2..41089121cc 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,47 @@ 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; + + 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 file_name = format!("Screenshot {date_time}.jpg"); + let path = screenshots_dir.join(file_name); + + image + .save_with_format(&path, image::ImageFormat::Jpeg) + .map_err(|e| format!("Failed to save screenshot: {e}"))?; + + let _ = NewScreenshotAdded { path: path.clone() }.emit(&app); + + notifications::send_notification(&app, notifications::NotificationType::ScreenshotSaved); + + AppSounds::StopRecording.play(); + + Ok(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/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 12cde9fef2..1ea90ff01b 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, } impl FromStr for CapWindowId { @@ -67,6 +68,7 @@ impl FromStr for CapWindowId { "upgrade" => Self::Upgrade, "mode-select" => Self::ModeSelect, "debug" => Self::Debug, + "screenshot-editor" => Self::ScreenshotEditor, s if s.starts_with("editor-") => Self::Editor { id: s .replace("editor-", "") @@ -110,6 +112,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 => write!(f, "screenshot-editor"), } } } @@ -127,6 +130,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 +144,7 @@ impl CapWindowId { Self::Setup | Self::Main | Self::Editor { .. } + | Self::ScreenshotEditor | Self::Settings | Self::Upgrade | Self::ModeSelect @@ -154,7 +159,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 +177,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 +215,9 @@ pub enum ShowCapWindow { }, Upgrade, ModeSelect, + ScreenshotEditor { + path: PathBuf, + }, } impl ShowCapWindow { @@ -414,6 +425,21 @@ 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() + .initialization_script(format!( + "window.__CAP__ = window.__CAP__ ?? {{}}; window.__CAP__.screenshotPath = {:?};", + path + )) + .build()? + } Self::Upgrade => { // Hide main window when upgrade window opens if let Some(main) = CapWindowId::Main.get(app) { @@ -803,6 +829,7 @@ impl ShowCapWindow { ShowCapWindow::InProgressRecording { .. } => CapWindowId::RecordingControls, ShowCapWindow::Upgrade => CapWindowId::Upgrade, ShowCapWindow::ModeSelect => CapWindowId::ModeSelect, + ShowCapWindow::ScreenshotEditor { .. } => CapWindowId::ScreenshotEditor, } } } diff --git a/apps/desktop/src/components/Mode.tsx b/apps/desktop/src/components/Mode.tsx index 17a2494f11..056fb08e66 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..8ca19afb1e 100644 --- a/apps/desktop/src/components/ModeSelect.tsx +++ b/apps/desktop/src/components/ModeSelect.tsx @@ -68,6 +68,13 @@ 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: IconCapCamera, + }, ]; return ( diff --git a/apps/desktop/src/routes/screenshot-editor/AspectRatioSelect.tsx b/apps/desktop/src/routes/screenshot-editor/AspectRatioSelect.tsx new file mode 100644 index 0000000000..e6578fb41a --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/AspectRatioSelect.tsx @@ -0,0 +1,105 @@ +import { Select as KSelect } from "@kobalte/core/select"; +import { createSignal, Show } from "solid-js"; +import Tooltip from "~/components/Tooltip"; +import type { AspectRatio } from "~/utils/tauri"; +import IconCapChevronDown from "~icons/cap/chevron-down"; +import IconCapCircleCheck from "~icons/cap/circle-check"; +import IconCapLayout from "~icons/cap/layout"; +import { ASPECT_RATIOS } from "../editor/projectConfig"; +import { useScreenshotEditorContext } from "./context"; +import { + EditorButton, + MenuItem, + MenuItemList, + PopperContent, + topLeftAnimateClasses, +} from "./ui"; + +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="top-start" + > + + as={KSelect.Trigger} + class="w-28" + leftIcon={} + rightIcon={ + + + + } + rightIconEnd={true} + > + > + {(state) => { + const text = () => { + const option = state.selectedOption(); + return option === "auto" ? "Auto" : ASPECT_RATIOS[option].name; + }; + return <>{text()}; + }} + + + + + as={KSelect.Content} + class={topLeftAnimateClasses} + > + + as={KSelect.Listbox} + class="w-[12.5rem]" + /> + + + + + ); +} + +export default AspectRatioSelect; diff --git a/apps/desktop/src/routes/screenshot-editor/ConfigSidebar.tsx b/apps/desktop/src/routes/screenshot-editor/ConfigSidebar.tsx new file mode 100644 index 0000000000..536fbe7a02 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/ConfigSidebar.tsx @@ -0,0 +1,979 @@ +import { + Collapsible, + Collapsible as KCollapsible, +} from "@kobalte/core/collapsible"; +import { + RadioGroup as KRadioGroup, + RadioGroup, +} from "@kobalte/core/radio-group"; +import { Select as KSelect } from "@kobalte/core/select"; +import { Tabs as KTabs } from "@kobalte/core/tabs"; +import { createEventListenerMap } from "@solid-primitives/event-listener"; +import { createWritableMemo } from "@solid-primitives/memo"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { appDataDir, resolveResource } from "@tauri-apps/api/path"; +import { BaseDirectory, writeFile } from "@tauri-apps/plugin-fs"; +import { type as ostype } from "@tauri-apps/plugin-os"; +import { cx } from "cva"; +import { + batch, + createMemo, + createResource, + createSignal, + For, + onMount, + Show, + type ValidComponent, +} from "solid-js"; +import { createStore } from "solid-js/store"; +import { Dynamic } from "solid-js/web"; +import toast from "solid-toast"; +import colorBg from "~/assets/illustrations/color.webp"; +import gradientBg from "~/assets/illustrations/gradient.webp"; +import imageBg from "~/assets/illustrations/image.webp"; +import transparentBg from "~/assets/illustrations/transparent.webp"; +import { Toggle } from "~/components/Toggle"; +import type { BackgroundSource } from "~/utils/tauri"; +import IconCapBgBlur from "~icons/cap/bg-blur"; +import IconCapChevronDown from "~icons/cap/chevron-down"; +import IconCapCircleX from "~icons/cap/circle-x"; +import IconCapCorners from "~icons/cap/corners"; +import IconCapEnlarge from "~icons/cap/enlarge"; +import IconCapImage from "~icons/cap/image"; +import IconCapPadding from "~icons/cap/padding"; +import IconCapSettings from "~icons/cap/settings"; +import IconCapShadow from "~icons/cap/shadow"; +import { + DEFAULT_GRADIENT_FROM, + DEFAULT_GRADIENT_TO, + type RGBColor, +} from "../editor/projectConfig"; +import { useScreenshotEditorContext } from "./context"; +import ShadowSettings from "./ShadowSettings"; +import { TextInput } from "./TextInput"; +import { + Field, + MenuItem, + MenuItemList, + PopperContent, + Slider, + topSlideAnimateClasses, +} from "./ui"; + +// Constants +const BACKGROUND_SOURCES = { + wallpaper: "Wallpaper", + image: "Image", + color: "Color", + gradient: "Gradient", +} satisfies Record; + +const BACKGROUND_ICONS = { + wallpaper: imageBg, + image: transparentBg, + color: colorBg, + gradient: gradientBg, +} satisfies Record; + +const BACKGROUND_SOURCES_LIST = [ + "wallpaper", + "image", + "color", + "gradient", +] satisfies Array; + +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 +]; + +// 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", +] as const; + +const BACKGROUND_THEMES = { + macOS: "macOS", + dark: "Dark", + blue: "Blue", + purple: "Purple", + orange: "Orange", +}; + +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 ConfigSidebar() { + const [selectedTab, setSelectedTab] = createSignal("background"); + let scrollRef!: HTMLDivElement; + + return ( + + + + {(item) => ( + +
+ +
+
+ )} +
+ + +
+ + + +
+ +
+ + ); +} + +function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { + const { project, setProject, projectHistory } = useScreenshotEditorContext(); + + // 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 (err) { + return { id, path: null }; + } + }); + + // Load initial batch + const initialPaths = await Promise.all(visibleWallpaperPaths); + + return initialPaths + .filter((p) => 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)) || []; + }); + + const [scrollX, setScrollX] = createSignal(0); + const [reachedEndOfScroll, setReachedEndOfScroll] = createSignal(false); + const [backgroundRef, setBackgroundRef] = createSignal(); + + createEventListenerMap( + () => backgroundRef() ?? [], + { + scroll: () => { + const el = backgroundRef(); + if (el) { + setScrollX(el.scrollLeft); + const reachedEnd = el.scrollWidth - el.clientWidth - el.scrollLeft; + setReachedEndOfScroll(reachedEnd === 0); + } + }, + wheel: (e: WheelEvent) => { + const el = backgroundRef(); + if (el) { + e.preventDefault(); + el.scrollLeft += + Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + } + }, + }, + { passive: false }, + ); + + let fileInput!: HTMLInputElement; + const hapticsEnabled = ostype() === "macos"; + + const setProjectSource = (source: any) => { + 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 = () => { + if (project.background.padding === 0) + setProject("background", "padding", 10); + }; + + return ( + + } name="Background Image"> + { + const tab = v as BackgroundSource["type"]; + let newSource: any; + 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="%" + /> + + +
+ + }> + setProject("background", "padding", v[0])} + minValue={0} + maxValue={40} + step={0.1} + formatTooltip="%" + /> + + + }> +
+ setProject("background", "rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + setProject("background", "roundingType", v)} + /> +
+
+ + }> + { + 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={0.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], + }); + }, + }} + /> + + + } + value={ + { + const prev = project.background.border ?? { + enabled: false, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }; + + if (props.scrollRef && enabled) { + setTimeout( + () => + props.scrollRef.scrollTo({ + top: props.scrollRef.scrollHeight, + behavior: "smooth", + }), + 100, + ); + } + + 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="%" + /> + +
+
+
+ + ); +} + +// Utils + +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} + /> + + + +
+ ); +} + +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 ( +
+
+ ); +} + +function rgbToHex(rgb: [number, number, number]) { + return `#${rgb + .map((c) => c.toString(16).padStart(2, "0")) + .join("") + .toUpperCase()}`; +} + +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..a39cbd5cda --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/Editor.tsx @@ -0,0 +1,292 @@ +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 { createSignal, Match, 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 { ConfigSidebar } from "./ConfigSidebar"; +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() { + 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..bdf62dab76 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx @@ -0,0 +1,126 @@ +import { Button } from "@cap/ui-solid"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { save } from "@tauri-apps/plugin-dialog"; +import { createSignal, Show } from "solid-js"; +import toast from "solid-toast"; +import { commands } from "~/utils/tauri"; +import IconCapCircleX from "~icons/cap/circle-x"; +import IconCapCopy from "~icons/cap/copy"; +import IconCapFile from "~icons/cap/file"; +import { useScreenshotEditorContext } from "./context"; +import { Dialog, DialogContent } from "./ui"; + +export function ExportDialog() { + const { dialog, setDialog, path, project } = useScreenshotEditorContext(); + const [exporting, setExporting] = createSignal(false); + + const exportImage = async (destination: "file" | "clipboard") => { + setExporting(true); + try { + // 1. Load the image + const img = new Image(); + img.src = convertFileSrc(path); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + }); + + // 2. Create canvas with appropriate dimensions + // We need to account for padding, crop, etc. + // For now, let's assume simple export of the original image + background settings + // This is a simplified implementation. A robust one would replicate the CSS effects on canvas. + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Could not get canvas context"); + + // Calculate dimensions based on project settings + // This logic needs to match Preview.tsx + const padding = project.background.padding * 2; // Scale factor? + // In Preview.tsx: padding: `${padding * 2}px` + // But here we are working with actual pixels. + // Let's assume padding is in pixels relative to the image size? + // Or we need a consistent scale. + // For simplicity, let's just use the image size + padding. + + // TODO: Implement proper rendering logic matching CSS + // For now, we'll just export the original image to demonstrate the flow + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + + // 3. Export + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png"), + ); + if (!blob) throw new Error("Failed to create blob"); + + const buffer = await blob.arrayBuffer(); + const uint8Array = new Uint8Array(buffer); + + if (destination === "file") { + const savePath = await save({ + filters: [{ name: "PNG Image", extensions: ["png"] }], + defaultPath: "screenshot.png", + }); + if (savePath) { + await commands.writeFile(savePath, Array.from(uint8Array)); + toast.success("Screenshot saved!"); + } + } else { + // Copy to clipboard + // We need a command for this as web API might be limited + // commands.copyImageToClipboard(uint8Array)? + // For now, let's use the existing command if it supports data + // commands.copyScreenshotToClipboard(path) copies the file at path. + // If we want to copy the *edited* image, we need to save it to a temp file first or send bytes. + + // Fallback to copying the original file for now + await commands.copyScreenshotToClipboard(path); + toast.success( + "Original screenshot copied to clipboard (editing export WIP)", + ); + } + + setDialog({ ...dialog(), open: false }); + } catch (err) { + console.error(err); + toast.error("Failed to export"); + } finally { + setExporting(false); + } + }; + + 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..62acc9e3cd --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -0,0 +1,174 @@ +import { Button } from "@cap/ui-solid"; +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, Show } from "solid-js"; +import Tooltip from "~/components/Tooltip"; +import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import { commands } from "~/utils/tauri"; +import IconCapTrash from "~icons/cap/trash"; +import IconLucideCopy from "~icons/lucide/copy"; +import IconLucideFolder from "~icons/lucide/folder"; +import { useScreenshotEditorContext } from "./context"; +import PresetsDropdown from "./PresetsDropdown"; +import { EditorButton } from "./ui"; + +export function Header() { + const { path, setDialog } = useScreenshotEditorContext(); + + // Extract filename from path + const filename = () => { + if (!path) return "Screenshot"; + const parts = path.split(/[/\\]/); + return parts[parts.length - 1] || "Screenshot"; + }; + + return ( +
+
+ {ostype() === "macos" &&
} + + { + if (await ask("Are you sure you want to delete this screenshot?")) { + await remove(path); + await getCurrentWindow().close(); + } + }} + tooltipText="Delete screenshot" + leftIcon={} + /> + { + revealItemInDir(path); + }} + tooltipText="Open containing folder" + leftIcon={} + /> + +
+ +
+
+ +
+ +
+ +
+ { + commands.copyScreenshotToClipboard(path); + }} + tooltipText="Copy to Clipboard" + leftIcon={} + /> + + + {ostype() === "windows" && } +
+
+ ); +} + +const UploadIcon = (props: any) => { + return ( + + + + + ); +}; + +function NameEditor(props: { name: string }) { + let prettyNameRef: HTMLInputElement | undefined; + let prettyNameMeasureRef: HTMLSpanElement | undefined; + const [truncated, setTruncated] = createSignal(false); + const [prettyName, setPrettyName] = createSignal(props.name); + + createEffect(() => { + if (!prettyNameRef || !prettyNameMeasureRef) return; + prettyNameMeasureRef.textContent = prettyName(); + const inputWidth = prettyNameRef.offsetWidth; + const textWidth = prettyNameMeasureRef.offsetWidth; + setTruncated(inputWidth < textWidth); + }); + + return ( + +
+ 100) && + "focus:border-red-500", + )} + value={prettyName()} + readOnly // Read only for now as we don't have rename logic + onInput={(e) => setPrettyName(e.currentTarget.value)} + onBlur={async () => { + setPrettyName(props.name); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === "Escape") { + prettyNameRef?.blur(); + } + }} + /> + {/* Hidden span for measuring text width */} + +
+
+ ); +} 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..655dca537a --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx @@ -0,0 +1,55 @@ +import { DropdownMenu as KDropdownMenu } from "@kobalte/core/dropdown-menu"; +import { cx } from "cva"; +import { Suspense } from "solid-js"; +import IconCapChevronDown from "~icons/cap/chevron-down"; +import IconCapCirclePlus from "~icons/cap/circle-plus"; +import IconCapPresets from "~icons/cap/presets"; +import { + DropdownItem, + EditorButton, + MenuItemList, + PopperContent, + topCenterAnimateClasses, +} from "./ui"; + +export function PresetsDropdown() { + return ( + + + as={KDropdownMenu.Trigger} + leftIcon={} + rightIcon={} + > + Presets + + + + + as={KDropdownMenu.Content} + class={cx("w-72 max-h-56", topCenterAnimateClasses)} + > + + 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 PresetsDropdown; 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..85d0b8521f --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/Preview.tsx @@ -0,0 +1,183 @@ +import { convertFileSrc } from "@tauri-apps/api/core"; +import { cx } from "cva"; +import { 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 { EditorButton, Slider } from "../editor/ui"; +import AspectRatioSelect from "./AspectRatioSelect"; +import { useScreenshotEditorContext } from "./context"; + +export function Preview() { + const { path, project, setDialog } = useScreenshotEditorContext(); + const [zoom, setZoom] = createSignal(1); + + // Background Style Helper + const backgroundStyle = createMemo(() => { + const source = project.background.source; + const blur = project.background.blur; + + const style: any = {}; + + if (source.type === "color") { + const [r, g, b] = source.value; + const a = source.alpha ?? 255; + style.backgroundColor = `rgba(${r}, ${g}, ${b}, ${a / 255})`; + } else if (source.type === "gradient") { + const { from, to, angle = 90 } = source; + style.background = `linear-gradient(${angle}deg, rgb(${from.join(",")}), rgb(${to.join(",")}))`; + } + + if (blur > 0) { + style.filter = `blur(${blur}px)`; + } + + return style; + }); + + // Image Style Helper + const imageStyle = createMemo(() => { + const { rounding, roundingType, shadow, advancedShadow, border } = + project.background; + + const style: any = {}; + + // Rounding + if (rounding > 0) { + style.borderRadius = `${rounding}px`; + } + + // Shadow + if (advancedShadow) { + const { size, opacity, blur } = advancedShadow; + style.boxShadow = `0 ${size / 2}px ${blur}px rgba(0,0,0, ${opacity / 100})`; + } else if (shadow > 0) { + style.boxShadow = `0 ${shadow / 2}px ${shadow}px rgba(0,0,0, ${shadow / 100})`; + } + + // Border + if (border?.enabled) { + const { width, color, opacity } = border; + const [r, g, b] = color; + style.border = `${width}px solid rgba(${r}, ${g}, ${b}, ${opacity / 100})`; + } + + return style; + }); + + const paddingStyle = createMemo(() => { + const padding = project.background.padding; + return { + padding: `${padding * 2}px`, // Scale padding for visibility + }; + }); + + const cropDialogHandler = () => { + const img = document.querySelector( + "img[data-screenshot-preview]", + ) as HTMLImageElement; + if (img) { + setDialog({ + open: true, + type: "crop", + position: { + ...(project.background.crop?.position ?? { x: 0, y: 0 }), + }, + size: { + ...(project.background.crop?.size ?? { + x: img.naturalWidth, + y: img.naturalHeight, + }), + }, + }); + } + }; + + return ( +
+ {/* Top Toolbar */} +
+ + } + > + Crop + +
+ + {/* Preview Area */} +
+
+ {/* Background Layer */} +
+ + + + + +
+
+ + {/* Content Layer */} +
+ +
+
+
+ + {/* Bottom Toolbar (Zoom) */} +
+
{/* Left side spacer or info */}
+ +
+
+ + + setZoom((z) => Math.max(0.1, z - 0.1))} + class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70 cursor-pointer" + /> + + + setZoom((z) => Math.min(3, z + 0.1))} + class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70 cursor-pointer" + /> + + setZoom(v)} + formatTooltip={(v) => `${Math.round(v * 100)}%`} + /> +
+
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/ShadowSettings.tsx b/apps/desktop/src/routes/screenshot-editor/ShadowSettings.tsx new file mode 100644 index 0000000000..b9bb7e6666 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/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/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..2981ceaa49 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/context.tsx @@ -0,0 +1,70 @@ +import { createContextProvider } from "@solid-primitives/context"; +import { createSignal } from "solid-js"; +import { createStore } from "solid-js/store"; +import type { AspectRatio, BackgroundConfiguration, XY } from "~/utils/tauri"; +import { + DEFAULT_GRADIENT_FROM, + DEFAULT_GRADIENT_TO, +} from "../editor/projectConfig"; + +export type ScreenshotProject = { + background: BackgroundConfiguration; + aspectRatio: AspectRatio | null; +}; + +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_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, +}; + +export const [ScreenshotEditorProvider, useScreenshotEditorContext] = + createContextProvider((props: { path: string }) => { + const [project, setProject] = + createStore(DEFAULT_PROJECT); + const [dialog, setDialog] = createSignal({ + open: false, + }); + + // Mock history for now or implement if needed + const projectHistory = { + pause: () => () => {}, + resume: () => {}, + undo: () => {}, + redo: () => {}, + canUndo: () => false, + canRedo: () => false, + isPaused: () => false, + }; + + return { + path: props.path, + project, + setProject, + projectHistory, + dialog, + setDialog, + }; + }, null!); 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..491dcec89c --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/index.tsx @@ -0,0 +1,49 @@ +import { Effect, getCurrentWindow } from "@tauri-apps/api/window"; +import { type as ostype } from "@tauri-apps/plugin-os"; +import { cx } from "cva"; +import { createEffect, createSignal, onMount, Show } from "solid-js"; +import { AbsoluteInsetLoader } from "~/components/Loader"; +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(); + const [path, setPath] = createSignal(null); + + onMount(() => { + // @ts-expect-error + const initialPath = window.__CAP__?.screenshotPath; + if (initialPath) { + setPath(initialPath); + } + }); + + createEffect(() => { + const transparent = generalSettings.data?.windowTransparency ?? false; + commands.setWindowTransparent(transparent); + getCurrentWindow().setEffects({ + effects: transparent ? [Effect.HudWindow] : [], + }); + }); + + return ( +
+ }> + {(p) => ( + + + + )} + +
+ ); +} 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..c26ed34dfc --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/ui.tsx @@ -0,0 +1,451 @@ +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 - Previous Recordings}> + Recordings}> - 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/screenshot-editor/ConfigSidebar.tsx b/apps/desktop/src/routes/screenshot-editor/ConfigSidebar.tsx index 536fbe7a02..f73625076a 100644 --- a/apps/desktop/src/routes/screenshot-editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/screenshot-editor/ConfigSidebar.tsx @@ -318,8 +318,18 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { }; const ensurePaddingForBackground = () => { - if (project.background.padding === 0) - setProject("background", "padding", 10); + 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 ( diff --git a/apps/desktop/src/routes/screenshot-editor/Preview.tsx b/apps/desktop/src/routes/screenshot-editor/Preview.tsx index d161d52848..988548c40b 100644 --- a/apps/desktop/src/routes/screenshot-editor/Preview.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Preview.tsx @@ -1,3 +1,4 @@ +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"; @@ -9,12 +10,28 @@ import { EditorButton, Slider } from "../editor/ui"; import AspectRatioSelect from "./AspectRatioSelect"; import { useScreenshotEditorContext } from "./context"; +// CSS for checkerboard grid (adaptive to light/dark mode) +const gridStyle = { + "background-image": + "linear-gradient(45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + + "linear-gradient(-45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + + "linear-gradient(45deg, transparent 75%, rgba(128,128,128,0.12) 75%), " + + "linear-gradient(-45deg, transparent 75%, rgba(128,128,128,0.12) 75%)", + "background-size": "40px 40px", + "background-position": "0 0, 0 20px, 20px -20px, -20px 0px", + "background-color": "rgba(200,200,200,0.08)", +}; + export function Preview() { const { path, project, setDialog, latestFrame } = useScreenshotEditorContext(); const [zoom, setZoom] = createSignal(1); let canvasRef: HTMLCanvasElement | undefined; + const [canvasContainerRef, setCanvasContainerRef] = + createSignal(); + const containerBounds = createElementBounds(canvasContainerRef); + createEffect(() => { const frame = latestFrame(); if (frame && canvasRef) { @@ -59,27 +76,82 @@ export function Preview() {
{/* Preview Area */} -
-
+ Loading preview...
} > - Loading preview...
} - > - {(frame) => ( - - )} - -
+ {(_) => { + 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 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 frameAspect = () => { + const width = frameWidth(); + const height = frameHeight(); + if (width === 0 || height === 0) return containerAspect(); + return width / height; + }; + + const size = () => { + let width: number; + let height: number; + if (frameAspect() < containerAspect()) { + height = availableHeight(); + width = height * frameAspect(); + } else { + width = availableWidth(); + height = width / frameAspect(); + } + + return { + width: Math.min(width, frameWidth()), + height: Math.min(height, frameHeight()), + }; + }; + + return ( +
+ +
+ ); + }} +
{/* Bottom Toolbar (Zoom) */} diff --git a/apps/desktop/src/routes/screenshot-editor/context.tsx b/apps/desktop/src/routes/screenshot-editor/context.tsx index b7d2eaf7a2..55f1606198 100644 --- a/apps/desktop/src/routes/screenshot-editor/context.tsx +++ b/apps/desktop/src/routes/screenshot-editor/context.tsx @@ -1,27 +1,23 @@ import { createContextProvider } from "@solid-primitives/context"; import { trackStore } from "@solid-primitives/deep"; +import { debounce } from "@solid-primitives/scheduled"; import { createEffect, createResource, createSignal, on } from "solid-js"; -import { createStore } from "solid-js/store"; +import { createStore, reconcile, unwrap } from "solid-js/store"; import { createImageDataWS, createLazySignal } from "~/utils/socket"; import { type AspectRatio, + type AudioConfiguration, type BackgroundConfiguration, + type Camera, + type CameraPosition, + type CursorConfiguration, commands, + type HotkeysConfiguration, + type ProjectConfiguration, type XY, } from "~/utils/tauri"; -import { - normalizeProject, - serializeProjectConfiguration, -} from "../editor/context"; -import { - DEFAULT_GRADIENT_FROM, - DEFAULT_GRADIENT_TO, -} from "../editor/projectConfig"; -export type ScreenshotProject = { - background: BackgroundConfiguration; - aspectRatio: AspectRatio | null; -}; +export type ScreenshotProject = ProjectConfiguration; export type CurrentDialog = | { type: "createPreset" } @@ -32,6 +28,46 @@ export type CurrentDialog = 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", +}; + +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: { @@ -49,10 +85,17 @@ const DEFAULT_PROJECT: ScreenshotProject = { border: null, }, aspectRatio: null, + camera: DEFAULT_CAMERA, + audio: DEFAULT_AUDIO, + cursor: DEFAULT_CURSOR, + hotkeys: DEFAULT_HOTKEYS, + timeline: null, + captions: null, + clips: [], }; export const [ScreenshotEditorProvider, useScreenshotEditorContext] = - createContextProvider((props: { path: string }) => { + createContextProvider(() => { const [project, setProject] = createStore(DEFAULT_PROJECT); const [dialog, setDialog] = createSignal({ @@ -65,9 +108,12 @@ export const [ScreenshotEditorProvider, useScreenshotEditorContext] = }>(); const [editorInstance] = createResource(async () => { - const instance = await commands.createScreenshotEditorInstance( - props.path, - ); + // @ts-expect-error - types not updated yet + const instance = await commands.createScreenshotEditorInstance(); + + if (instance.config) { + setProject(reconcile(instance.config)); + } const [_ws, isConnected] = createImageDataWS( instance.framesSocketUrl, @@ -77,33 +123,21 @@ export const [ScreenshotEditorProvider, useScreenshotEditorContext] = return instance; }); + const saveConfig = debounce((config: ProjectConfiguration) => { + // @ts-expect-error - command signature update + commands.updateScreenshotConfig(config, true); + }, 1000); + createEffect( - on( - () => trackStore(project), - async () => { - const instance = editorInstance(); - if (!instance) return; - - // Convert ScreenshotProject to ProjectConfiguration - // We need to construct a full ProjectConfiguration from the partial ScreenshotProject - // For now, we can use a default one and override background - const config = serializeProjectConfiguration({ - ...normalizeProject({ - // @ts-expect-error - partial config - background: project.background, - // @ts-expect-error - partial config - camera: { - source: { type: "none" }, - }, - }), - // @ts-expect-error - aspectRatio: project.aspectRatio, - }); - - await commands.updateScreenshotConfig(instance, config); - }, - { defer: true }, - ), + on([() => trackStore(project), editorInstance], async ([, instance]) => { + if (!instance) return; + + const config = unwrap(project); + + // @ts-expect-error - command signature update + commands.updateScreenshotConfig(config, false); + saveConfig(config); + }), ); // Mock history for now or implement if needed @@ -118,7 +152,9 @@ export const [ScreenshotEditorProvider, useScreenshotEditorContext] = }; return { - path: props.path, + get path() { + return editorInstance()?.path ?? ""; + }, project, setProject, projectHistory, diff --git a/apps/desktop/src/routes/screenshot-editor/index.tsx b/apps/desktop/src/routes/screenshot-editor/index.tsx index 491dcec89c..f1d993c603 100644 --- a/apps/desktop/src/routes/screenshot-editor/index.tsx +++ b/apps/desktop/src/routes/screenshot-editor/index.tsx @@ -1,8 +1,7 @@ import { Effect, getCurrentWindow } from "@tauri-apps/api/window"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; -import { createEffect, createSignal, onMount, Show } from "solid-js"; -import { AbsoluteInsetLoader } from "~/components/Loader"; +import { createEffect } from "solid-js"; import { generalSettingsStore } from "~/store"; import { commands } from "~/utils/tauri"; import { ScreenshotEditorProvider } from "./context"; @@ -10,15 +9,6 @@ import { Editor } from "./Editor"; export default function ScreenshotEditorRoute() { const generalSettings = generalSettingsStore.createQuery(); - const [path, setPath] = createSignal(null); - - onMount(() => { - // @ts-expect-error - const initialPath = window.__CAP__?.screenshotPath; - if (initialPath) { - setPath(initialPath); - } - }); createEffect(() => { const transparent = generalSettings.data?.windowTransparency ?? false; @@ -37,13 +27,9 @@ export default function ScreenshotEditorRoute() { ) && "bg-transparent-window", )} > - }> - {(p) => ( - - - - )} - + + +
); } From f9450c89e32f51e2cf39c18c6c5706c489c1558f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:55:35 +0000 Subject: [PATCH 07/44] Add screenshot mode and image icon support --- apps/desktop/src/components/Mode.tsx | 5 +- .../src/routes/target-select-overlay.tsx | 76 ++++++++++++++----- apps/desktop/src/utils/tauri.ts | 10 +-- packages/ui-solid/icons/image-filled.svg | 3 + packages/ui-solid/src/auto-imports.d.ts | 1 + 5 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 packages/ui-solid/icons/image-filled.svg diff --git a/apps/desktop/src/components/Mode.tsx b/apps/desktop/src/components/Mode.tsx index 056fb08e66..7fa7e524b6 100644 --- a/apps/desktop/src/components/Mode.tsx +++ b/apps/desktop/src/components/Mode.tsx @@ -3,6 +3,7 @@ import { createSignal } from "solid-js"; import Tooltip from "~/components/Tooltip"; import { useRecordingOptions } from "~/routes/(window-chrome)/OptionsContext"; import { commands } from "~/utils/tauri"; +import IconCapImageFilled from "~icons/cap/image-filled"; const Mode = () => { const { rawOptions, setOptions } = useRecordingOptions(); @@ -80,7 +81,7 @@ const Mode = () => { : "bg-gray-3 hover:bg-gray-7" }`} > - +
)} @@ -123,7 +124,7 @@ const Mode = () => { : "bg-gray-3 hover:bg-gray-7" }`} > - +
)} diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index f2d55a3156..c2e3af0c4f 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -479,6 +479,7 @@ function Inner() { }); createEffect(async () => { + if (options.mode === "screenshot") return; const bounds = crop(); const interacting = isInteracting(); @@ -688,6 +689,43 @@ function Inner() { }); }); + const [wasInteracting, setWasInteracting] = createSignal(false); + createEffect(async () => { + const interacting = isInteracting(); + const was = wasInteracting(); + setWasInteracting(interacting); + + if (was && !interacting) { + if (options.mode === "screenshot" && isValid()) { + const target: ScreenCaptureTarget = { + variant: "area", + screen: displayId(), + bounds: { + position: { + x: crop().x, + y: crop().y, + }, + size: { + width: crop().width, + height: crop().height, + }, + }, + }; + + try { + const path = await invoke("take_screenshot", { + target, + }); + // @ts-expect-error + await commands.showWindow({ ScreenshotEditor: { path } }); + await commands.closeTargetSelectOverlays(); + } catch (e) { + console.error("Failed to take screenshot", e); + } + } + } + }); + return (
- + setOriginalCameraBounds(null)} - /> + }} + disabled={!isValid()} + showBackground={controllerInside()} + onRecordingStart={() => setOriginalCameraBounds(null)} + /> +

Minimum size is 150 x 150

diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 5d8bf386c5..2092cea142 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -134,11 +134,11 @@ async uploadExportedVideo(path: string, mode: UploadMode, channel: TAURI_CHANNEL async uploadScreenshot(screenshotPath: string) : Promise { return await TAURI_INVOKE("upload_screenshot", { screenshotPath }); }, -async createScreenshotEditorInstance(path: string) : Promise { - return await TAURI_INVOKE("create_screenshot_editor_instance", { path }); +async createScreenshotEditorInstance() : Promise { + return await TAURI_INVOKE("create_screenshot_editor_instance"); }, -async updateScreenshotConfig(config: ProjectConfiguration) : Promise { - return await TAURI_INVOKE("update_screenshot_config", { config }); +async updateScreenshotConfig(config: ProjectConfiguration, save: boolean) : Promise { + return await TAURI_INVOKE("update_screenshot_config", { config, save }); }, async getRecordingMeta(path: string, fileType: FileType) : Promise { return await TAURI_INVOKE("get_recording_meta", { path, fileType }); @@ -481,7 +481,7 @@ export type SceneSegment = { start: number; end: number; mode?: SceneMode } export type ScreenCaptureTarget = { variant: "window"; id: WindowId } | { variant: "display"; id: DisplayId } | { variant: "area"; screen: DisplayId; bounds: LogicalBounds } export type SegmentRecordings = { display: Video; camera: Video | null; mic: Audio | null; system_audio: Audio | null } export type SerializedEditorInstance = { framesSocketUrl: string; recordingDuration: number; savedProjectConfig: ProjectConfiguration; recordings: ProjectRecordingsMeta; path: string } -export type SerializedScreenshotEditorInstance = { framesSocketUrl: string; path: string } +export type SerializedScreenshotEditorInstance = { framesSocketUrl: string; path: string; config: ProjectConfiguration | null } export type SetCaptureAreaPending = boolean export type ShadowConfiguration = { size: number; opacity: number; blur: number } export type SharingMeta = { id: string; link: string } diff --git a/packages/ui-solid/icons/image-filled.svg b/packages/ui-solid/icons/image-filled.svg new file mode 100644 index 0000000000..f5329b6bc6 --- /dev/null +++ b/packages/ui-solid/icons/image-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 7bb78817bb..4db5e5ed5f 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -74,6 +74,7 @@ declare global { const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default'] const IconLucideGift: typeof import('~icons/lucide/gift.jsx')['default'] const IconLucideHardDrive: typeof import('~icons/lucide/hard-drive.jsx')['default'] + const IconLucideImage: typeof import('~icons/lucide/image.jsx')['default'] const IconLucideLayout: typeof import('~icons/lucide/layout.jsx')['default'] const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default'] const IconLucideMaximize: typeof import('~icons/lucide/maximize.jsx')['default'] From 84d1baa4cf40683da6f70a7392d6f1d57dae7179 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:55:52 +0000 Subject: [PATCH 08/44] Refactor screenshot saving and editor window management --- apps/desktop/src-tauri/src/lib.rs | 21 +++++++++- apps/desktop/src-tauri/src/recording.rs | 51 ++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1580756f92..b3ef2aa2bb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -59,7 +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::{create_screenshot_editor_instance, update_screenshot_config}; +use screenshot_editor::{ + ScreenshotEditorInstances, create_screenshot_editor_instance, update_screenshot_config, +}; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; @@ -86,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, @@ -2453,6 +2457,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()); @@ -2687,6 +2692,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) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 41089121cc..29dbe2afd7 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1046,20 +1046,61 @@ pub async fn take_screenshot( chrono::Local::now().format("%Y-%m-%d %H:%M:%S") }; - let file_name = format!("Screenshot {date_time}.jpg"); - let path = screenshots_dir.join(file_name); + 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); image - .save_with_format(&path, image::ImageFormat::Jpeg) + .save_with_format(&image_path, image::ImageFormat::Png) .map_err(|e| format!("Failed to save screenshot: {e}"))?; - let _ = NewScreenshotAdded { path: path.clone() }.emit(&app); + // 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(path) + Ok(image_path) } // runs when a recording ends, whether from success or failure From f28cffdcd6048765863b405721d756909d148145 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:15:52 +0000 Subject: [PATCH 09/44] Add recordings grid and menu to main window --- .../(window-chrome)/new-main/TargetCard.tsx | 44 ++++- .../new-main/TargetMenuGrid.tsx | 56 +++++- .../routes/(window-chrome)/new-main/index.tsx | 179 ++++++++++++++---- apps/desktop/src/utils/queries.ts | 8 + 4 files changed, 246 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx index e794b21656..85c07c4db4 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx @@ -1,13 +1,18 @@ +import { convertFileSrc } from "@tauri-apps/api/core"; import { cx } from "cva"; import type { ComponentProps } from "solid-js"; -import { createMemo, Show, splitProps } from "solid-js"; +import { createMemo, createSignal, Show, splitProps } from "solid-js"; import type { CaptureDisplayWithThumbnail, CaptureWindowWithThumbnail, + RecordingMetaWithMetadata, } from "~/utils/tauri"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; +import IconLucideSquarePlay from "~icons/lucide/square-play"; import IconMdiMonitor from "~icons/mdi/monitor"; +export type RecordingWithPath = RecordingMetaWithMetadata & { path: string }; + function formatResolution(width?: number, height?: number) { if (!width || !height) return undefined; @@ -34,6 +39,10 @@ type TargetCardProps = ( variant: "window"; target: CaptureWindowWithThumbnail; } + | { + variant: "recording"; + target: RecordingWithPath; + } ) & Omit, "children"> & { highlightQuery?: string; @@ -47,6 +56,7 @@ export default function TargetCard(props: TargetCardProps) { "disabled", "highlightQuery", ]); + const [imageExists, setImageExists] = createSignal(true); const displayTarget = createMemo(() => { if (local.variant !== "display") return undefined; @@ -58,21 +68,38 @@ export default function TargetCard(props: TargetCardProps) { return local.target as CaptureWindowWithThumbnail; }); + const recordingTarget = createMemo(() => { + if (local.variant !== "recording") return undefined; + return local.target as RecordingWithPath; + }); + const renderIcon = (className: string) => local.variant === "display" ? ( - ) : ( + ) : local.variant === "window" ? ( + ) : ( + ); const label = createMemo(() => { const display = displayTarget(); if (display) return display.name; const target = windowTarget(); - return target?.name || target?.owner_name; + if (target) return target.name || target.owner_name; + const recording = recordingTarget(); + return recording?.pretty_name; }); - const subtitle = createMemo(() => windowTarget()?.owner_name); + const subtitle = createMemo(() => { + const target = windowTarget(); + if (target) return target.owner_name; + const recording = recordingTarget(); + if (recording) { + return recording.mode === "studio" ? "Studio Mode" : "Instant Mode"; + } + return undefined; + }); const metadata = createMemo(() => { if (local.variant === "window") { @@ -94,6 +121,12 @@ export default function TargetCard(props: TargetCardProps) { }); const thumbnailSrc = createMemo(() => { + const recording = recordingTarget(); + if (recording) { + return `${convertFileSrc( + `${recording.path}/screenshots/display.jpg`, + )}?t=${Date.now()}`; + } const target = displayTarget() ?? windowTarget(); if (!target?.thumbnail) return undefined; return `data:image/png;base64,${target.thumbnail}`; @@ -142,7 +175,7 @@ export default function TargetCard(props: TargetCardProps) { >
{renderIcon("size-6 text-gray-9 opacity-70")} @@ -157,6 +190,7 @@ export default function TargetCard(props: TargetCardProps) { class="object-cover w-full h-full" loading="lazy" draggable={false} + onError={() => setImageExists(false)} /> 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..37dc79d358 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,10 @@ import type { CaptureDisplayWithThumbnail, CaptureWindowWithThumbnail, } from "~/utils/tauri"; -import TargetCard, { TargetCardSkeleton } from "./TargetCard"; +import TargetCard, { + type RecordingWithPath, + TargetCardSkeleton, +} from "./TargetCard"; const DEFAULT_SKELETON_COUNT = 6; @@ -29,7 +32,14 @@ type WindowGridProps = BaseProps & { variant: "window"; }; -type TargetMenuGridProps = DisplayGridProps | WindowGridProps; +type RecordingGridProps = BaseProps & { + variant: "recording"; +}; + +type TargetMenuGridProps = + | DisplayGridProps + | WindowGridProps + | RecordingGridProps; export default function TargetMenuGrid(props: TargetMenuGridProps) { const items = createMemo(() => props.targets ?? []); @@ -97,7 +107,11 @@ 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" + : "No recordings 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} + /> +
+
+ )} +
+ ); + })()} +
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 668f5afd58..5d8256ff2f 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -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,6 +54,7 @@ import { type CaptureWindowWithThumbnail, commands, type DeviceOrModelID, + type RecordingMetaWithMetadata, type RecordingTargetMode, type ScreenCaptureTarget, } from "~/utils/tauri"; @@ -71,6 +73,7 @@ import CameraSelect from "./CameraSelect"; import ChangelogButton from "./ChangeLogButton"; import MicrophoneSelect from "./MicrophoneSelect"; import SystemAudio from "./SystemAudio"; +import type { RecordingWithPath } from "./TargetCard"; import TargetDropdownButton from "./TargetDropdownButton"; import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; @@ -140,6 +143,12 @@ type TargetMenuPanelProps = variant: "window"; targets?: CaptureWindowWithThumbnail[]; onSelect: (target: CaptureWindowWithThumbnail) => void; + } + | { + variant: "recording"; + targets?: RecordingWithPath[]; + onSelect: (target: RecordingWithPath) => void; + onViewAll: () => void; }; type SharedTargetMenuProps = { @@ -154,11 +163,17 @@ 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" + : "Search recordings"; const noResultsMessage = props.variant === "display" ? "No matching displays" - : "No matching windows"; + : props.variant === "window" + ? "No matching windows" + : "No matching recordings"; const filteredDisplayTargets = createMemo( () => { @@ -193,6 +208,18 @@ 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)); + }); + return (
@@ -206,25 +233,43 @@ 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} + /> + + } + > + +
@@ -240,7 +285,7 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { highlightQuery={trimmedSearch()} emptyMessage={trimmedSearch() ? noResultsMessage : undefined} /> - ) : ( + ) : props.variant === "window" ? ( + ) : ( + )}
+ + {/* Removed sticky footer button */} +
); @@ -327,11 +386,15 @@ function Page() { const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); - const activeMenu = createMemo<"display" | "window" | null>(() => { - if (displayMenuOpen()) return "display"; - if (windowMenuOpen()) return "window"; - return null; - }); + const [recordingsMenuOpen, setRecordingsMenuOpen] = createSignal(false); + const activeMenu = createMemo<"display" | "window" | "recording" | null>( + () => { + if (displayMenuOpen()) return "display"; + if (windowMenuOpen()) return "window"; + if (recordingsMenuOpen()) return "recording"; + return null; + }, + ); const [hasOpenedDisplayMenu, setHasOpenedDisplayMenu] = createSignal(false); const [hasOpenedWindowMenu, setHasOpenedWindowMenu] = createSignal(false); @@ -348,6 +411,8 @@ function Page() { refetchInterval: false, })); + const recordings = useQuery(() => listRecordings); + const screens = useQuery(() => listScreens); const windows = useQuery(() => listWindows); @@ -380,6 +445,18 @@ 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 displayMenuLoading = () => !hasDisplayTargetsData() && (displayTargets.status === "pending" || @@ -431,6 +508,7 @@ function Page() { if (!isRecording()) return; setDisplayMenuOpen(false); setWindowMenuOpen(false); + setRecordingsMenuOpen(false); }); createUpdateCheck(); @@ -805,11 +883,15 @@ function Page() { Recordings}> +
From f92090aed32db23eb141e6824b2e8179c2aead4e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:57:11 +0000 Subject: [PATCH 11/44] Add screenshot support to target selection UI --- .../(window-chrome)/new-main/TargetCard.tsx | 131 +++++++++++++--- .../routes/(window-chrome)/new-main/index.tsx | 142 +++++++++++++++--- 2 files changed, 231 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx index 85c07c4db4..54bd7d3634 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx @@ -1,17 +1,25 @@ import { convertFileSrc } from "@tauri-apps/api/core"; +import { save } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import type { ComponentProps } from "solid-js"; import { createMemo, createSignal, Show, splitProps } from "solid-js"; -import type { - CaptureDisplayWithThumbnail, - CaptureWindowWithThumbnail, - RecordingMetaWithMetadata, +import { + type CaptureDisplayWithThumbnail, + type CaptureWindowWithThumbnail, + commands, + type RecordingMeta, + type RecordingMetaWithMetadata, } from "~/utils/tauri"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; +import IconLucideCopy from "~icons/lucide/copy"; +import IconLucideEdit from "~icons/lucide/edit"; +import IconLucideImage from "~icons/lucide/image"; +import IconLucideSave from "~icons/lucide/save"; import IconLucideSquarePlay from "~icons/lucide/square-play"; import IconMdiMonitor from "~icons/mdi/monitor"; export type RecordingWithPath = RecordingMetaWithMetadata & { path: string }; +export type ScreenshotWithPath = RecordingMeta & { path: string }; function formatResolution(width?: number, height?: number) { if (!width || !height) return undefined; @@ -43,6 +51,10 @@ type TargetCardProps = ( variant: "recording"; target: RecordingWithPath; } + | { + variant: "screenshot"; + target: ScreenshotWithPath; + } ) & Omit, "children"> & { highlightQuery?: string; @@ -73,13 +85,20 @@ export default function TargetCard(props: TargetCardProps) { return local.target as RecordingWithPath; }); + const screenshotTarget = createMemo(() => { + if (local.variant !== "screenshot") return undefined; + return local.target as ScreenshotWithPath; + }); + const renderIcon = (className: string) => local.variant === "display" ? ( ) : local.variant === "window" ? ( - ) : ( + ) : local.variant === "recording" ? ( + ) : ( + ); const label = createMemo(() => { @@ -88,7 +107,9 @@ export default function TargetCard(props: TargetCardProps) { const target = windowTarget(); if (target) return target.name || target.owner_name; const recording = recordingTarget(); - return recording?.pretty_name; + if (recording) return recording.pretty_name; + const screenshot = screenshotTarget(); + return screenshot?.pretty_name; }); const subtitle = createMemo(() => { @@ -127,6 +148,10 @@ export default function TargetCard(props: TargetCardProps) { `${recording.path}/screenshots/display.jpg`, )}?t=${Date.now()}`; } + const screenshot = screenshotTarget(); + if (screenshot) { + return `${convertFileSrc(screenshot.path)}?t=${Date.now()}`; + } const target = displayTarget() ?? windowTarget(); if (!target?.thumbnail) return undefined; return `data:image/png;base64,${target.thumbnail}`; @@ -161,6 +186,42 @@ export default function TargetCard(props: TargetCardProps) { }); }; + const handleOpenEditor = (e: MouseEvent) => { + e.stopPropagation(); + const screenshot = screenshotTarget(); + if (!screenshot) return; + commands.showWindow({ + ScreenshotEditor: { + path: screenshot.path, + }, + }); + }; + + const handleCopy = async (e: MouseEvent) => { + e.stopPropagation(); + const screenshot = screenshotTarget(); + if (!screenshot) return; + await commands.copyScreenshotToClipboard(screenshot.path); + }; + + const handleSave = async (e: MouseEvent) => { + e.stopPropagation(); + const screenshot = screenshotTarget(); + if (!screenshot) return; + const path = await save({ + defaultPath: screenshot.pretty_name + ".png", + filters: [ + { + name: "Image", + extensions: ["png"], + }, + ], + }); + if (path) { + await commands.copyFileToPath(screenshot.path, path); + } + }; + return ( + + +
+
); 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 13487a43c9..455a723e01 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, @@ -73,7 +73,7 @@ import CameraSelect from "./CameraSelect"; import ChangelogButton from "./ChangeLogButton"; import MicrophoneSelect from "./MicrophoneSelect"; import SystemAudio from "./SystemAudio"; -import type { RecordingWithPath } from "./TargetCard"; +import type { RecordingWithPath, ScreenshotWithPath } from "./TargetCard"; import TargetDropdownButton from "./TargetDropdownButton"; import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; @@ -149,6 +149,12 @@ type TargetMenuPanelProps = targets?: RecordingWithPath[]; onSelect: (target: RecordingWithPath) => void; onViewAll: () => void; + } + | { + variant: "screenshot"; + targets?: ScreenshotWithPath[]; + onSelect: (target: ScreenshotWithPath) => void; + onViewAll: () => void; }; type SharedTargetMenuProps = { @@ -167,13 +173,17 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { ? "Search displays" : props.variant === "window" ? "Search windows" - : "Search recordings"; + : props.variant === "recording" + ? "Search recordings" + : "Search screenshots"; const noResultsMessage = props.variant === "display" ? "No matching displays" : props.variant === "window" ? "No matching windows" - : "No matching recordings"; + : props.variant === "recording" + ? "No matching recordings" + : "No matching screenshots"; const filteredDisplayTargets = createMemo( () => { @@ -220,6 +230,18 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { 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 (
@@ -234,7 +256,9 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) {
@@ -266,8 +290,14 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { if ("onViewAll" in props) props.onViewAll(); }} > - - View All Recordings + {props.variant === "recording" ? ( + + ) : ( + + )} + {props.variant === "recording" + ? "View All Recordings" + : "View All Screenshots"}
@@ -296,7 +326,7 @@ function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { highlightQuery={trimmedSearch()} emptyMessage={trimmedSearch() ? noResultsMessage : undefined} /> - ) : ( + ) : props.variant === "recording" ? ( + ) : ( + )}
- + {/* Removed sticky footer button */}
@@ -387,14 +430,16 @@ function Page() { const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); const [recordingsMenuOpen, setRecordingsMenuOpen] = createSignal(false); - const activeMenu = createMemo<"display" | "window" | "recording" | null>( - () => { - if (displayMenuOpen()) return "display"; - if (windowMenuOpen()) return "window"; - if (recordingsMenuOpen()) return "recording"; - return null; - }, - ); + 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); const [hasOpenedWindowMenu, setHasOpenedWindowMenu] = createSignal(false); @@ -412,6 +457,19 @@ function Page() { })); 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); @@ -457,6 +515,12 @@ function Page() { .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" || @@ -509,6 +573,7 @@ function Page() { setDisplayMenuOpen(false); setWindowMenuOpen(false); setRecordingsMenuOpen(false); + setScreenshotsMenuOpen(false); }); createUpdateCheck(); @@ -889,6 +954,7 @@ function Page() { if (next) { setDisplayMenuOpen(false); setWindowMenuOpen(false); + setScreenshotsMenuOpen(false); } return next; }); @@ -901,11 +967,16 @@ function Page() { Screenshots}>
)} @@ -124,7 +124,7 @@ const Mode = () => { : "bg-gray-3 hover:bg-gray-7" }`} > - + )} diff --git a/apps/desktop/src/components/Tooltip.tsx b/apps/desktop/src/components/Tooltip.tsx index c208e7598d..606dcc3b4d 100644 --- a/apps/desktop/src/components/Tooltip.tsx +++ b/apps/desktop/src/components/Tooltip.tsx @@ -1,9 +1,9 @@ -import { Tooltip as KTooltip } from "@kobalte/core"; +import { Tooltip as KTooltip } from "@kobalte/core/tooltip"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import type { ComponentProps, JSX } from "solid-js"; -interface Props extends ComponentProps { +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)/new-main/ChangeLogButton.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx index 755e749c5c..132b271d45 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx @@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"; import Tooltip from "~/components/Tooltip"; import { commands } from "~/utils/tauri"; import { apiClient } from "~/utils/web-api"; +import IconLucideBell from "~icons/lucide/bell"; const ChangelogButton = () => { const [changelogState, setChangelogState] = makePersisted( 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 37dc79d358..2531ba96d7 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx @@ -155,7 +155,6 @@ export default function TargetMenuGrid(props: TargetMenuGridProps) { enterToClass="scale-100 opacity-100" exitActiveClass="transition duration-200" exitClass="scale-100" - exitToClass="scale-100" exitToClass="scale-95" >
+ 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 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/AspectRatioSelect.tsx b/apps/desktop/src/routes/screenshot-editor/AspectRatioSelect.tsx deleted file mode 100644 index e6578fb41a..0000000000 --- a/apps/desktop/src/routes/screenshot-editor/AspectRatioSelect.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Select as KSelect } from "@kobalte/core/select"; -import { createSignal, Show } from "solid-js"; -import Tooltip from "~/components/Tooltip"; -import type { AspectRatio } from "~/utils/tauri"; -import IconCapChevronDown from "~icons/cap/chevron-down"; -import IconCapCircleCheck from "~icons/cap/circle-check"; -import IconCapLayout from "~icons/cap/layout"; -import { ASPECT_RATIOS } from "../editor/projectConfig"; -import { useScreenshotEditorContext } from "./context"; -import { - EditorButton, - MenuItem, - MenuItemList, - PopperContent, - topLeftAnimateClasses, -} from "./ui"; - -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="top-start" - > - - as={KSelect.Trigger} - class="w-28" - leftIcon={} - rightIcon={ - - - - } - rightIconEnd={true} - > - > - {(state) => { - const text = () => { - const option = state.selectedOption(); - return option === "auto" ? "Auto" : ASPECT_RATIOS[option].name; - }; - return <>{text()}; - }} - - - - - as={KSelect.Content} - class={topLeftAnimateClasses} - > - - as={KSelect.Listbox} - class="w-[12.5rem]" - /> - - - - - ); -} - -export default AspectRatioSelect; 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/ConfigSidebar.tsx b/apps/desktop/src/routes/screenshot-editor/ConfigSidebar.tsx deleted file mode 100644 index f73625076a..0000000000 --- a/apps/desktop/src/routes/screenshot-editor/ConfigSidebar.tsx +++ /dev/null @@ -1,989 +0,0 @@ -import { - Collapsible, - Collapsible as KCollapsible, -} from "@kobalte/core/collapsible"; -import { - RadioGroup as KRadioGroup, - RadioGroup, -} from "@kobalte/core/radio-group"; -import { Select as KSelect } from "@kobalte/core/select"; -import { Tabs as KTabs } from "@kobalte/core/tabs"; -import { createEventListenerMap } from "@solid-primitives/event-listener"; -import { createWritableMemo } from "@solid-primitives/memo"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { appDataDir, resolveResource } from "@tauri-apps/api/path"; -import { BaseDirectory, writeFile } from "@tauri-apps/plugin-fs"; -import { type as ostype } from "@tauri-apps/plugin-os"; -import { cx } from "cva"; -import { - batch, - createMemo, - createResource, - createSignal, - For, - onMount, - Show, - type ValidComponent, -} from "solid-js"; -import { createStore } from "solid-js/store"; -import { Dynamic } from "solid-js/web"; -import toast from "solid-toast"; -import colorBg from "~/assets/illustrations/color.webp"; -import gradientBg from "~/assets/illustrations/gradient.webp"; -import imageBg from "~/assets/illustrations/image.webp"; -import transparentBg from "~/assets/illustrations/transparent.webp"; -import { Toggle } from "~/components/Toggle"; -import type { BackgroundSource } from "~/utils/tauri"; -import IconCapBgBlur from "~icons/cap/bg-blur"; -import IconCapChevronDown from "~icons/cap/chevron-down"; -import IconCapCircleX from "~icons/cap/circle-x"; -import IconCapCorners from "~icons/cap/corners"; -import IconCapEnlarge from "~icons/cap/enlarge"; -import IconCapImage from "~icons/cap/image"; -import IconCapPadding from "~icons/cap/padding"; -import IconCapSettings from "~icons/cap/settings"; -import IconCapShadow from "~icons/cap/shadow"; -import { - DEFAULT_GRADIENT_FROM, - DEFAULT_GRADIENT_TO, - type RGBColor, -} from "../editor/projectConfig"; -import { useScreenshotEditorContext } from "./context"; -import ShadowSettings from "./ShadowSettings"; -import { TextInput } from "./TextInput"; -import { - Field, - MenuItem, - MenuItemList, - PopperContent, - Slider, - topSlideAnimateClasses, -} from "./ui"; - -// Constants -const BACKGROUND_SOURCES = { - wallpaper: "Wallpaper", - image: "Image", - color: "Color", - gradient: "Gradient", -} satisfies Record; - -const BACKGROUND_ICONS = { - wallpaper: imageBg, - image: transparentBg, - color: colorBg, - gradient: gradientBg, -} satisfies Record; - -const BACKGROUND_SOURCES_LIST = [ - "wallpaper", - "image", - "color", - "gradient", -] satisfies Array; - -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 -]; - -// 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", -] as const; - -const BACKGROUND_THEMES = { - macOS: "macOS", - dark: "Dark", - blue: "Blue", - purple: "Purple", - orange: "Orange", -}; - -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 ConfigSidebar() { - const [selectedTab, setSelectedTab] = createSignal("background"); - let scrollRef!: HTMLDivElement; - - return ( - - - - {(item) => ( - -
- -
-
- )} -
- - -
- - - -
- -
- - ); -} - -function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { - const { project, setProject, projectHistory } = useScreenshotEditorContext(); - - // 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 (err) { - return { id, path: null }; - } - }); - - // Load initial batch - const initialPaths = await Promise.all(visibleWallpaperPaths); - - return initialPaths - .filter((p) => 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)) || []; - }); - - const [scrollX, setScrollX] = createSignal(0); - const [reachedEndOfScroll, setReachedEndOfScroll] = createSignal(false); - const [backgroundRef, setBackgroundRef] = createSignal(); - - createEventListenerMap( - () => backgroundRef() ?? [], - { - scroll: () => { - const el = backgroundRef(); - if (el) { - setScrollX(el.scrollLeft); - const reachedEnd = el.scrollWidth - el.clientWidth - el.scrollLeft; - setReachedEndOfScroll(reachedEnd === 0); - } - }, - wheel: (e: WheelEvent) => { - const el = backgroundRef(); - if (el) { - e.preventDefault(); - el.scrollLeft += - Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; - } - }, - }, - { passive: false }, - ); - - let fileInput!: HTMLInputElement; - const hapticsEnabled = ostype() === "macos"; - - const setProjectSource = (source: any) => { - 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 ( - - } name="Background Image"> - { - const tab = v as BackgroundSource["type"]; - let newSource: any; - 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="%" - /> - - -
- - }> - setProject("background", "padding", v[0])} - minValue={0} - maxValue={40} - step={0.1} - formatTooltip="%" - /> - - - }> -
- setProject("background", "rounding", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - setProject("background", "roundingType", v)} - /> -
-
- - }> - { - 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={0.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], - }); - }, - }} - /> - - - } - value={ - { - const prev = project.background.border ?? { - enabled: false, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }; - - if (props.scrollRef && enabled) { - setTimeout( - () => - props.scrollRef.scrollTo({ - top: props.scrollRef.scrollHeight, - behavior: "smooth", - }), - 100, - ); - } - - 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="%" - /> - -
-
-
- - ); -} - -// Utils - -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} - /> - - - -
- ); -} - -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 ( -
-
- ); -} - -function rgbToHex(rgb: [number, number, number]) { - return `#${rgb - .map((c) => c.toString(16).padStart(2, "0")) - .join("") - .toUpperCase()}`; -} - -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 index a39cbd5cda..59b303f6d5 100644 --- a/apps/desktop/src/routes/screenshot-editor/Editor.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Editor.tsx @@ -4,7 +4,14 @@ 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 { createSignal, Match, Show, Switch } from "solid-js"; +import { + createEffect, + createSignal, + Match, + onCleanup, + Show, + Switch, +} from "solid-js"; import { Transition } from "solid-transition-group"; import { CROP_ZERO, @@ -18,7 +25,6 @@ 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 { ConfigSidebar } from "./ConfigSidebar"; import { useScreenshotEditorContext } from "./context"; import { ExportDialog } from "./ExportDialog"; import { Header } from "./Header"; @@ -26,17 +32,98 @@ 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 ( <> -
+
-
- - +
+
diff --git a/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx b/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx index bdf62dab76..65858297e0 100644 --- a/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx +++ b/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx @@ -1,53 +1,196 @@ import { Button } from "@cap/ui-solid"; import { convertFileSrc } from "@tauri-apps/api/core"; import { save } from "@tauri-apps/plugin-dialog"; -import { createSignal, Show } from "solid-js"; +import { createSignal } from "solid-js"; import toast from "solid-toast"; import { commands } from "~/utils/tauri"; -import IconCapCircleX from "~icons/cap/circle-x"; import IconCapCopy from "~icons/cap/copy"; import IconCapFile from "~icons/cap/file"; -import { useScreenshotEditorContext } from "./context"; +import { type Annotation, useScreenshotEditorContext } from "./context"; import { Dialog, DialogContent } from "./ui"; export function ExportDialog() { - const { dialog, setDialog, path, project } = useScreenshotEditorContext(); + const { dialog, setDialog, path, latestFrame, annotations } = + useScreenshotEditorContext(); const [exporting, setExporting] = createSignal(false); + const drawAnnotations = ( + ctx: CanvasRenderingContext2D, + annotations: Annotation[], + ) => { + for (const ann of annotations) { + if (ann.type === "mask") continue; + ctx.save(); + ctx.globalAlpha = ann.opacity; + ctx.strokeStyle = ann.strokeColor; + ctx.lineWidth = ann.strokeWidth; + ctx.fillStyle = ann.fillColor; + + if (ann.type === "rectangle") { + if (ann.fillColor !== "transparent") { + ctx.fillRect(ann.x, ann.y, ann.width, ann.height); + } + ctx.strokeRect(ann.x, ann.y, ann.width, ann.height); + } else if (ann.type === "circle") { + ctx.beginPath(); + const cx = ann.x + ann.width / 2; + const cy = ann.y + ann.height / 2; + const rx = Math.abs(ann.width / 2); + const ry = Math.abs(ann.height / 2); + ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI); + if (ann.fillColor !== "transparent") { + ctx.fill(); + } + ctx.stroke(); + } else if (ann.type === "arrow") { + ctx.beginPath(); + const x1 = ann.x; + const y1 = ann.y; + const x2 = ann.x + ann.width; + const y2 = ann.y + ann.height; + + // Line + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + + // Arrowhead + const angle = Math.atan2(y2 - y1, x2 - x1); + const headLen = 10 + ann.strokeWidth; // scale with stroke? + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo( + x2 - headLen * Math.cos(angle - Math.PI / 6), + y2 - headLen * Math.sin(angle - Math.PI / 6), + ); + ctx.lineTo( + x2 - headLen * Math.cos(angle + Math.PI / 6), + y2 - headLen * Math.sin(angle + Math.PI / 6), + ); + ctx.lineTo(x2, y2); + ctx.fillStyle = ann.strokeColor; + ctx.fill(); + } else if (ann.type === "text" && ann.text) { + ctx.fillStyle = ann.strokeColor; // Text uses stroke color + ctx.font = `${ann.height}px sans-serif`; + ctx.fillText(ann.text, ann.x, ann.y + ann.height); // text baseline bottomish + } + + ctx.restore(); + } + }; + + const applyMaskAnnotations = ( + ctx: CanvasRenderingContext2D, + source: HTMLCanvasElement, + annotations: Annotation[], + ) => { + for (const ann of annotations) { + if (ann.type !== "mask") continue; + + const startX = Math.max(0, Math.min(ann.x, ann.x + ann.width)); + const startY = Math.max(0, Math.min(ann.y, ann.y + ann.height)); + const endX = Math.min(source.width, Math.max(ann.x, ann.x + ann.width)); + const endY = Math.min(source.height, Math.max(ann.y, ann.y + ann.height)); + + const regionWidth = endX - startX; + const regionHeight = endY - startY; + if (regionWidth <= 0 || regionHeight <= 0) continue; + + const level = Math.max(1, ann.maskLevel ?? 16); + const type = ann.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, + ); + const previousSmoothing = ctx.imageSmoothingEnabled; + ctx.imageSmoothingEnabled = false; + ctx.drawImage( + temp, + 0, + 0, + temp.width, + temp.height, + startX, + startY, + regionWidth, + regionHeight, + ); + ctx.imageSmoothingEnabled = previousSmoothing; + continue; + } + + ctx.save(); + ctx.beginPath(); + ctx.rect(startX, startY, regionWidth, regionHeight); + ctx.clip(); + ctx.filter = `blur(${level}px)`; + ctx.drawImage( + source, + startX, + startY, + regionWidth, + regionHeight, + startX, + startY, + regionWidth, + regionHeight, + ); + ctx.restore(); + } + ctx.filter = "none"; + }; + const exportImage = async (destination: "file" | "clipboard") => { setExporting(true); try { - // 1. Load the image - const img = new Image(); - img.src = convertFileSrc(path); - await new Promise((resolve, reject) => { - img.onload = resolve; - img.onerror = reject; - }); - - // 2. Create canvas with appropriate dimensions - // We need to account for padding, crop, etc. - // For now, let's assume simple export of the original image + background settings - // This is a simplified implementation. A robust one would replicate the CSS effects on canvas. - const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not get canvas context"); - // Calculate dimensions based on project settings - // This logic needs to match Preview.tsx - const padding = project.background.padding * 2; // Scale factor? - // In Preview.tsx: padding: `${padding * 2}px` - // But here we are working with actual pixels. - // Let's assume padding is in pixels relative to the image size? - // Or we need a consistent scale. - // For simplicity, let's just use the image size + padding. - - // TODO: Implement proper rendering logic matching CSS - // For now, we'll just export the original image to demonstrate the flow - canvas.width = img.width; - canvas.height = img.height; - ctx.drawImage(img, 0, 0); + const frame = latestFrame(); + if (frame) { + canvas.width = frame.width; + canvas.height = frame.data.height; + ctx.putImageData(frame.data, 0, 0); + } else { + // Fallback to loading file + const img = new Image(); + img.src = convertFileSrc(path); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + }); + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + } + + const sourceCanvas = document.createElement("canvas"); + sourceCanvas.width = canvas.width; + sourceCanvas.height = canvas.height; + const sourceCtx = sourceCanvas.getContext("2d"); + if (!sourceCtx) throw new Error("Could not get source canvas context"); + sourceCtx.drawImage(canvas, 0, 0); + + applyMaskAnnotations(ctx, sourceCanvas, annotations); + drawAnnotations(ctx, annotations); // 3. Export const blob = await new Promise((resolve) => diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx index 62acc9e3cd..46c75070c8 100644 --- a/apps/desktop/src/routes/screenshot-editor/Header.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -1,174 +1,166 @@ 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, Show } from "solid-js"; +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 PresetsDropdown from "./PresetsDropdown"; -import { EditorButton } from "./ui"; +import PresetsSubMenu from "./PresetsDropdown"; +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 } = useScreenshotEditorContext(); + const { path, setDialog, project, latestFrame } = + useScreenshotEditorContext(); - // Extract filename from path - const filename = () => { - if (!path) return "Screenshot"; - const parts = path.split(/[/\\]/); - return parts[parts.length - 1] || "Screenshot"; + 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" &&
} +
+ {ostype() === "macos" &&
} +
+
+ { - if (await ask("Are you sure you want to delete this screenshot?")) { - await remove(path); - await getCurrentWindow().close(); - } - }} - tooltipText="Delete screenshot" - leftIcon={} + tooltipText="Crop Image" + onClick={cropDialogHandler} + leftIcon={} /> - { - revealItemInDir(path); - }} - tooltipText="Open containing folder" - leftIcon={} - /> - -
- -
+
+ +
+ + + + +
- -
- -
+
+ { commands.copyScreenshotToClipboard(path); }} tooltipText="Copy to Clipboard" - leftIcon={} + leftIcon={} /> - - {ostype() === "windows" && } -
-
- ); -} + setDialog({ type: "export", open: true })} + leftIcon={} + /> -const UploadIcon = (props: any) => { - return ( - - - - - ); -}; + + + 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 + + -function NameEditor(props: { name: string }) { - let prettyNameRef: HTMLInputElement | undefined; - let prettyNameMeasureRef: HTMLSpanElement | undefined; - const [truncated, setTruncated] = createSignal(false); - const [prettyName, setPrettyName] = createSignal(props.name); + - createEffect(() => { - if (!prettyNameRef || !prettyNameMeasureRef) return; - prettyNameMeasureRef.textContent = prettyName(); - const inputWidth = prettyNameRef.offsetWidth; - const textWidth = prettyNameMeasureRef.offsetWidth; - setTruncated(inputWidth < textWidth); - }); + + as={DropdownMenu.Group} + class="p-1" + > + + + + + + - return ( - -
- 100) && - "focus:border-red-500", - )} - value={prettyName()} - readOnly // Read only for now as we don't have rename logic - onInput={(e) => setPrettyName(e.currentTarget.value)} - onBlur={async () => { - setPrettyName(props.name); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === "Escape") { - prettyNameRef?.blur(); - } - }} - /> - {/* Hidden span for measuring text width */} - + {ostype() === "windows" && }
-
+
); } diff --git a/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx b/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx index 655dca537a..b4cf0ae267 100644 --- a/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx +++ b/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx @@ -1,32 +1,34 @@ import { DropdownMenu as KDropdownMenu } from "@kobalte/core/dropdown-menu"; import { cx } from "cva"; import { Suspense } from "solid-js"; -import IconCapChevronDown from "~icons/cap/chevron-down"; import IconCapCirclePlus from "~icons/cap/circle-plus"; import IconCapPresets from "~icons/cap/presets"; +import IconLucideChevronRight from "~icons/lucide/chevron-right"; import { DropdownItem, - EditorButton, MenuItemList, PopperContent, - topCenterAnimateClasses, + topSlideAnimateClasses, } from "./ui"; -export function PresetsDropdown() { +export function PresetsSubMenu() { return ( - - - as={KDropdownMenu.Trigger} - leftIcon={} - rightIcon={} + + - Presets -
+
+ + Presets +
+ + - - as={KDropdownMenu.Content} - class={cx("w-72 max-h-56", topCenterAnimateClasses)} + + as={KDropdownMenu.SubContent} + class={cx("w-72 max-h-56", topSlideAnimateClasses)} > as={KDropdownMenu.Group} @@ -48,8 +50,8 @@ export function PresetsDropdown() { - + ); } -export default PresetsDropdown; +export default PresetsSubMenu; diff --git a/apps/desktop/src/routes/screenshot-editor/Preview.tsx b/apps/desktop/src/routes/screenshot-editor/Preview.tsx index 988548c40b..a0362d96dc 100644 --- a/apps/desktop/src/routes/screenshot-editor/Preview.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Preview.tsx @@ -6,32 +6,50 @@ 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 AspectRatioSelect from "./AspectRatioSelect"; import { useScreenshotEditorContext } from "./context"; +import { AspectRatioSelect } from "./popovers/AspectRatioSelect"; -// CSS for checkerboard grid (adaptive to light/dark mode) +// CSS for checkerboard grid const gridStyle = { + "background-color": "white", "background-image": - "linear-gradient(45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + - "linear-gradient(-45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + - "linear-gradient(45deg, transparent 75%, rgba(128,128,128,0.12) 75%), " + - "linear-gradient(-45deg, transparent 75%, rgba(128,128,128,0.12) 75%)", - "background-size": "40px 40px", - "background-position": "0 0, 0 20px, 20px -20px, -20px 0px", - "background-color": "rgba(200,200,200,0.08)", + "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", }; -export function Preview() { - const { path, project, setDialog, latestFrame } = +import { AnnotationLayer } from "./AnnotationLayer"; + +export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { + const { path, project, setDialog, latestFrame, annotations, activeTool } = useScreenshotEditorContext(); - const [zoom, setZoom] = createSignal(1); 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) { @@ -42,44 +60,38 @@ export function Preview() { } }); - const cropDialogHandler = () => { - // We use the original image for cropping - // We can get dimensions from the latest frame or load the image - // For now, let's just open the dialog and let it handle loading - setDialog({ - open: true, - type: "crop", - position: { - ...(project.background.crop?.position ?? { x: 0, y: 0 }), - }, - size: { - ...(project.background.crop?.size ?? { - x: latestFrame()?.width ?? 0, - y: latestFrame()?.height ?? 0, - }), - }, - }); - }; - return ( -
- {/* Top Toolbar */} -
- - } - > - Crop - -
- +
{/* 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...
} @@ -99,6 +111,63 @@ export function Preview() { 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 = () => @@ -111,9 +180,9 @@ export function Preview() { return width / height; }; - const frameAspect = () => { - const width = frameWidth(); - const height = frameHeight(); + const contentAspect = () => { + const width = bounds().width; + const height = bounds().height; if (width === 0 || height === 0) return containerAspect(); return width / height; }; @@ -121,69 +190,199 @@ export function Preview() { const size = () => { let width: number; let height: number; - if (frameAspect() < containerAspect()) { + if (contentAspect() < containerAspect()) { height = availableHeight(); - width = height * frameAspect(); + width = height * contentAspect(); } else { width = availableWidth(); - height = width / frameAspect(); + height = width / contentAspect(); } return { - width: Math.min(width, frameWidth()), - height: Math.min(height, frameHeight()), + 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 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; + } + + ctx.save(); + ctx.beginPath(); + ctx.rect(startX, startY, regionWidth, regionHeight); + ctx.clip(); + ctx.filter = `blur(${level}px)`; + ctx.drawImage( + source, + startX, + startY, + regionWidth, + regionHeight, + startX, + startY, + regionWidth, + regionHeight, + ); + ctx.restore(); + } + + ctx.filter = "none"; + }; + + createEffect(renderMaskOverlays); + return (
- + class="shadow-lg block" + > + + { + 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", + }} + /> + +
); }}
- - {/* Bottom Toolbar (Zoom) */} -
-
{/* Left side spacer or info */}
- -
-
- - - setZoom((z) => Math.max(0.1, z - 0.1))} - class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70 cursor-pointer" - /> - - - setZoom((z) => Math.min(3, z + 0.1))} - class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70 cursor-pointer" - /> - - setZoom(v)} - formatTooltip={(v) => `${Math.round(v * 100)}%`} - /> -
-
); } diff --git a/apps/desktop/src/routes/screenshot-editor/context.tsx b/apps/desktop/src/routes/screenshot-editor/context.tsx index 55f1606198..a2de8e9720 100644 --- a/apps/desktop/src/routes/screenshot-editor/context.tsx +++ b/apps/desktop/src/routes/screenshot-editor/context.tsx @@ -1,15 +1,15 @@ 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 AspectRatio, + type Annotation, + type AnnotationType, type AudioConfiguration, - type BackgroundConfiguration, type Camera, - type CameraPosition, type CursorConfiguration, commands, type HotkeysConfiguration, @@ -18,6 +18,7 @@ import { } from "~/utils/tauri"; export type ScreenshotProject = ProjectConfiguration; +export type { Annotation, AnnotationType }; export type CurrentDialog = | { type: "createPreset" } @@ -39,7 +40,7 @@ const DEFAULT_CAMERA: Camera = { advancedShadow: null, shape: "square", roundingType: "squircle", -}; +} as unknown as Camera; const DEFAULT_AUDIO: AudioConfiguration = { mute: false, @@ -92,75 +93,228 @@ const DEFAULT_PROJECT: ScreenshotProject = { timeline: null, captions: null, clips: [], -}; + annotations: [], +} as unknown as ScreenshotProject; -export const [ScreenshotEditorProvider, useScreenshotEditorContext] = - createContextProvider(() => { - const [project, setProject] = - createStore(DEFAULT_PROJECT); - const [dialog, setDialog] = createSignal({ - open: false, - }); - - const [latestFrame, setLatestFrame] = createLazySignal<{ - width: number; - data: ImageData; - }>(); - - const [editorInstance] = createResource(async () => { - // @ts-expect-error - types not updated yet - const instance = await commands.createScreenshotEditorInstance(); - - if (instance.config) { - setProject(reconcile(instance.config)); +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)); } + } - const [_ws, isConnected] = createImageDataWS( - instance.framesSocketUrl, - setLatestFrame, - ); + // 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 }); + } + }; + } - return instance; - }); + const [_ws, _isConnected] = createImageDataWS( + instance.framesSocketUrl, + setLatestFrame, + ); - const saveConfig = debounce((config: ProjectConfiguration) => { - // @ts-expect-error - command signature update - commands.updateScreenshotConfig(config, true); - }, 1000); + return instance; + }); - createEffect( - on([() => trackStore(project), editorInstance], async ([, 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); + const config = { + ...unwrap(project), + annotations: unwrap(annotations), + }; - // @ts-expect-error - command signature update 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[]; + }; - // Mock history for now or implement if needed - const projectHistory = { - pause: () => () => {}, - resume: () => {}, - undo: () => {}, - redo: () => {}, - canUndo: () => false, - canRedo: () => false, - isPaused: () => false, + 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", []); + }; - return { - get path() { - return editorInstance()?.path ?? ""; - }, - project, - setProject, - projectHistory, - dialog, - setDialog, - latestFrame, - editorInstance, + 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; + }); }; - }, null!); + }; + + 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/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/ShadowSettings.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/ShadowSettings.tsx similarity index 98% rename from apps/desktop/src/routes/screenshot-editor/ShadowSettings.tsx rename to apps/desktop/src/routes/screenshot-editor/popovers/ShadowSettings.tsx index b9bb7e6666..e2eca87f22 100644 --- a/apps/desktop/src/routes/screenshot-editor/ShadowSettings.tsx +++ b/apps/desktop/src/routes/screenshot-editor/popovers/ShadowSettings.tsx @@ -2,7 +2,7 @@ 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"; +import { Field, Slider } from "../ui"; interface Props { size: { diff --git a/apps/desktop/src/routes/screenshot-editor/ui.tsx b/apps/desktop/src/routes/screenshot-editor/ui.tsx index c26ed34dfc..1f35bba049 100644 --- a/apps/desktop/src/routes/screenshot-editor/ui.tsx +++ b/apps/desktop/src/routes/screenshot-editor/ui.tsx @@ -392,6 +392,7 @@ export function EditorButton( ( & any, + props: ComponentProps & { as?: ValidComponent }, ) { const [trigger, root] = splitProps(props, ["children", "as"]); return ( diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 2092cea142..918b3898e4 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -358,6 +358,8 @@ uploadProgressEvent: "upload-progress-event" /** user-defined types **/ +export type Annotation = { id: string; type: AnnotationType; x: number; y: number; width: number; height: number; strokeColor: string; strokeWidth: number; fillColor: string; opacity: number; rotation: number; text: string | null; maskType?: MaskType | null; maskLevel?: number | null } +export type AnnotationType = "arrow" | "circle" | "rectangle" | "text" | "mask" export type AppTheme = "system" | "light" | "dark" export type AspectRatio = "wide" | "vertical" | "square" | "classic" | "tall" export type Audio = { duration: number; sample_rate: number; channels: number; start_time: number } @@ -373,7 +375,7 @@ export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; roundingType?: CornerStyle; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null; border?: BorderConfiguration | null } export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number]; alpha?: number } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number } export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number } -export type Camera = { hide: boolean; mirror: boolean; position: CameraPosition; size: number; zoom_size: number | null; rounding?: number; shadow?: number; advanced_shadow?: ShadowConfiguration | null; shape?: CameraShape; rounding_type?: CornerStyle } +export type Camera = { hide: boolean; mirror: boolean; position: CameraPosition; size: number; zoomSize: number | null; rounding?: number; shadow?: number; advancedShadow?: ShadowConfiguration | null; shape?: CameraShape; roundingType?: CornerStyle } export type CameraInfo = { device_id: string; model_id: ModelIDType | null; display_name: string } export type CameraPosition = { x: CameraXPosition; y: CameraYPosition } export type CameraPreviewShape = "round" | "square" | "full" @@ -436,6 +438,7 @@ export type LogicalBounds = { position: LogicalPosition; size: LogicalSize } export type LogicalPosition = { x: number; y: number } export type LogicalSize = { width: number; height: number } export type MainWindowRecordingStartBehaviour = "close" | "minimise" +export type MaskType = "blur" | "pixelate" export type ModelIDType = string export type Mp4ExportSettings = { fps: number; resolution_base: XY; compression: ExportCompression } export type MultipleSegment = { display: VideoMeta; camera?: VideoMeta | null; mic?: AudioMeta | null; system_audio?: AudioMeta | null; cursor?: string | null } @@ -455,7 +458,7 @@ export type PostDeletionBehaviour = "doNothing" | "reopenRecordingWindow" export type PostStudioRecordingBehaviour = "openEditor" | "showOverlay" export type Preset = { name: string; config: ProjectConfiguration } export type PresetsStore = { presets: Preset[]; default: number | null } -export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: Camera; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration; timeline?: TimelineConfiguration | null; captions?: CaptionsData | null; clips?: ClipConfiguration[] } +export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: Camera; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration; timeline?: TimelineConfiguration | null; captions?: CaptionsData | null; clips?: ClipConfiguration[]; annotations?: Annotation[] } export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingAction = "Started" | "InvalidAuthentication" | "UpgradeRequired" export type RecordingDeleted = { path: string } diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000000..fd657dbac2 --- /dev/null +++ b/bun.lock @@ -0,0 +1,239 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "cap", + "devDependencies": { + "@biomejs/biome": "2.2.0", + "@clack/prompts": "^0.10.0", + "@effect/language-service": "^0.44.0", + "dotenv-cli": "latest", + "mysql2": "^3.15.2", + "tsdown": "^0.15.6", + "turbo": "^2.3.4", + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@biomejs/biome": ["@biomejs/biome@2.2.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.0", "@biomejs/cli-darwin-x64": "2.2.0", "@biomejs/cli-linux-arm64": "2.2.0", "@biomejs/cli-linux-arm64-musl": "2.2.0", "@biomejs/cli-linux-x64": "2.2.0", "@biomejs/cli-linux-x64-musl": "2.2.0", "@biomejs/cli-win32-arm64": "2.2.0", "@biomejs/cli-win32-x64": "2.2.0" }, "bin": { "biome": "bin/biome" } }, "sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww=="], + + "@clack/core": ["@clack/core@0.4.2", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg=="], + + "@clack/prompts": ["@clack/prompts@0.10.1", "", { "dependencies": { "@clack/core": "0.4.2", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw=="], + + "@effect/language-service": ["@effect/language-service@0.44.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-BH5F8B1CFbkL2iaX8Ly5OFO7wHfWVc+FJ7xxF+XF2tVOW1gaWxxyBdvyPpZSZDbL8wp+Fii7NH5lXh+Q9W7Tqg=="], + + "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + + "@oxc-project/types": ["@oxc-project/types@0.95.0", "", {}, "sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ=="], + + "@quansync/fs": ["@quansync/fs@0.1.5", "", { "dependencies": { "quansync": "^0.2.11" } }, "sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.45", "", { "os": "android", "cpu": "arm64" }, "sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.45", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.45", "", { "os": "darwin", "cpu": "x64" }, "sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.45", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45", "", { "os": "linux", "cpu": "arm" }, "sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45", "", { "os": "linux", "cpu": "arm64" }, "sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.45", "", { "os": "linux", "cpu": "arm64" }, "sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.45", "", { "os": "linux", "cpu": "x64" }, "sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.45", "", { "os": "linux", "cpu": "x64" }, "sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.45", "", { "os": "none", "cpu": "arm64" }, "sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.45", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.7" }, "cpu": "none" }, "sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45", "", { "os": "win32", "cpu": "arm64" }, "sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ=="], + + "@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45", "", { "os": "win32", "cpu": "ia32" }, "sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.45", "", { "os": "win32", "cpu": "x64" }, "sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.45", "", {}, "sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + + "ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="], + + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + + "birpc": ["birpc@2.8.0", "", {}, "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + + "dotenv-cli": ["dotenv-cli@11.0.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "dotenv": "^17.1.0", "dotenv-expand": "^12.0.0", "minimist": "^1.2.6" }, "bin": { "dotenv": "cli.js" } }, "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww=="], + + "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], + + "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "lru.min": ["lru.min@1.1.3", "", {}, "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], + + "named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="], + + "obug": ["obug@2.1.0", "", {}, "sha512-uu/tgLPoa75CFA7UDkmqspKbefvZh1WMPwkU3bNr0PY746a/+xwXVgbw5co5C3GvJj3h5u8g/pbxXzI0gd1QFg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rolldown": ["rolldown@1.0.0-beta.45", "", { "dependencies": { "@oxc-project/types": "=0.95.0", "@rolldown/pluginutils": "1.0.0-beta.45" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.45", "@rolldown/binding-darwin-arm64": "1.0.0-beta.45", "@rolldown/binding-darwin-x64": "1.0.0-beta.45", "@rolldown/binding-freebsd-x64": "1.0.0-beta.45", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.45", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.45", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.45", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.45", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.45", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.45", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.45", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.45", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.45", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.45" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ=="], + + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.17.8", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ast-kit": "^2.2.0", "birpc": "^2.8.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.0", "magic-string": "^0.30.21", "obug": "^2.0.0" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-beta.44", "typescript": "^5.0.0", "vue-tsc": "~3.1.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-76EEBlhF00yeY6M7VpMkWKI4r9WjuoMiOGey7j4D6zf3m0BR+ZrrY9hvSXdueJ3ljxSLq4DJBKFpX/X9+L7EKw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "tsdown": ["tsdown@0.15.12", "", { "dependencies": { "ansis": "^4.2.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "debug": "^4.4.3", "diff": "^8.0.2", "empathic": "^2.0.0", "hookable": "^5.5.3", "rolldown": "1.0.0-beta.45", "rolldown-plugin-dts": "^0.17.2", "semver": "^7.7.3", "tinyexec": "^1.0.1", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", "unconfig": "^7.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", "unplugin-unused": "^0.5.0", "unrun": "^0.2.1" }, "optionalPeers": ["@arethetypeswrong/core", "publint", "typescript", "unplugin-lightningcss", "unplugin-unused", "unrun"], "bin": { "tsdown": "dist/run.mjs" } }, "sha512-c8VLlQm8/lFrOAg5VMVeN4NAbejZyVQkzd+ErjuaQgJFI/9MhR9ivr0H/CM7UlOF1+ELlF6YaI7sU/4itgGQ8w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "turbo": ["turbo@2.6.1", "", { "optionalDependencies": { "turbo-darwin-64": "2.6.1", "turbo-darwin-arm64": "2.6.1", "turbo-linux-64": "2.6.1", "turbo-linux-arm64": "2.6.1", "turbo-windows-64": "2.6.1", "turbo-windows-arm64": "2.6.1" }, "bin": { "turbo": "bin/turbo" } }, "sha512-qBwXXuDT3rA53kbNafGbT5r++BrhRgx3sAo0cHoDAeG9g1ItTmUMgltz3Hy7Hazy1ODqNpR+C7QwqL6DYB52yA=="], + + "turbo-darwin-64": ["turbo-darwin-64@2.6.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dm0HwhyZF4J0uLqkhUyCVJvKM9Rw7M03v3J9A7drHDQW0qAbIGBrUijQ8g4Q9Cciw/BXRRd8Uzkc3oue+qn+ZQ=="], + + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.6.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U0PIPTPyxdLsrC3jN7jaJUwgzX5sVUBsKLO7+6AL+OASaa1NbT1pPdiZoTkblBAALLP76FM0LlnsVQOnmjYhyw=="], + + "turbo-linux-64": ["turbo-linux-64@2.6.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eM1uLWgzv89bxlK29qwQEr9xYWBhmO/EGiH22UGfq+uXr+QW1OvNKKMogSN65Ry8lElMH4LZh0aX2DEc7eC0Mw=="], + + "turbo-linux-arm64": ["turbo-linux-arm64@2.6.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MFFh7AxAQAycXKuZDrbeutfWM5Ep0CEZ9u7zs4Hn2FvOViTCzIfEhmuJou3/a5+q5VX1zTxQrKGy+4Lf5cdpsA=="], + + "turbo-windows-64": ["turbo-windows-64@2.6.1", "", { "os": "win32", "cpu": "x64" }, "sha512-buq7/VAN7KOjMYi4tSZT5m+jpqyhbRU2EUTTvp6V0Ii8dAkY2tAAjQN1q5q2ByflYWKecbQNTqxmVploE0LVwQ=="], + + "turbo-windows-arm64": ["turbo-windows-arm64@2.6.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-7w+AD5vJp3R+FB0YOj1YJcNcOOvBior7bcHTodqp90S3x3bLgpr7tE6xOea1e8JkP7GK6ciKVUpQvV7psiwU5Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unconfig": ["unconfig@7.4.1", "", { "dependencies": { "@quansync/fs": "^0.1.5", "defu": "^6.1.4", "jiti": "^2.6.1", "quansync": "^0.2.11", "unconfig-core": "7.4.1" } }, "sha512-uyQ7LElcGizrOGZyIq9KU+xkuEjcRf9IpmDTkCSYv5mEeZzrXSj6rb51C0L+WTedsmAoVxW9WKrLWhSwebIM9Q=="], + + "unconfig-core": ["unconfig-core@7.4.1", "", { "dependencies": { "@quansync/fs": "^0.1.5", "quansync": "^0.2.11" } }, "sha512-Bp/bPZjV2Vl/fofoA2OYLSnw1Z0MOhCX7zHnVCYrazpfZvseBbGhwcNQMxsg185Mqh7VZQqK3C8hFG/Dyng+yA=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + } +} diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index cd3bf369b0..6b469ece44 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -111,24 +111,24 @@ impl + Copy> Sub for XY { } } -impl + Copy> Mul for XY { +impl + Copy> Mul> for XY { type Output = Self; - fn mul(self, other: T) -> Self { + fn mul(self, other: Self) -> Self { Self { - x: self.x * other, - y: self.y * other, + x: self.x * other.x, + y: self.y * other.y, } } } -impl + Copy> Mul> for XY { +impl + Copy> Mul for XY { type Output = Self; - fn mul(self, other: Self) -> Self { + fn mul(self, other: T) -> Self { Self { - x: self.x * other.x, - y: self.y * other.y, + x: self.x * other, + y: self.y * other, } } } @@ -289,6 +289,7 @@ pub struct CameraPosition { } #[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] pub struct Camera { pub hide: bool, pub mirror: bool, @@ -668,6 +669,45 @@ pub struct ClipConfiguration { pub offsets: ClipOffsets, } +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum AnnotationType { + Arrow, + Circle, + Rectangle, + Text, + Mask, +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum MaskType { + Blur, + Pixelate, +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Annotation { + pub id: String, + #[serde(rename = "type")] + pub annotation_type: AnnotationType, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub stroke_color: String, + pub stroke_width: f64, + pub fill_color: String, + pub opacity: f64, + pub rotation: f64, + pub text: Option, + #[serde(default)] + pub mask_type: Option, + #[serde(default)] + pub mask_level: Option, +} + #[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] #[serde(rename_all = "camelCase")] pub struct ProjectConfiguration { @@ -683,6 +723,8 @@ pub struct ProjectConfiguration { pub captions: Option, #[serde(default)] pub clips: Vec, + #[serde(default)] + pub annotations: Vec, } impl ProjectConfiguration { diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 4db5e5ed5f..6bf33b06f4 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -85,6 +85,7 @@ declare global { const IconLucideRatio: typeof import('~icons/lucide/ratio.jsx')['default'] const IconLucideRectangleHorizontal: typeof import('~icons/lucide/rectangle-horizontal.jsx')['default'] const IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw.jsx')['default'] + const IconLucideSave: typeof import('~icons/lucide/save.jsx')['default'] const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default'] const IconLucideSquarePlay: typeof import('~icons/lucide/square-play.jsx')['default'] const IconLucideUnplug: typeof import('~icons/lucide/unplug.jsx')['default'] From 6dcb6f2dec1bf4b1671609a624d93673461ac2d0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:03:01 +0000 Subject: [PATCH 13/44] Refactor mask blur logic for screenshot editor --- .../screenshot-editor/AnnotationConfig.tsx | 9 +-- .../routes/screenshot-editor/ExportDialog.tsx | 60 +++++++++++++------ .../src/routes/screenshot-editor/Preview.tsx | 56 +++++++++++++---- 3 files changed, 93 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx index 754914fce1..fe4ef91e6f 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx @@ -1,5 +1,5 @@ import { Popover } from "@kobalte/core/popover"; -import { createMemo, For, Show } from "solid-js"; +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"; @@ -30,12 +30,13 @@ export function AnnotationConfig() { {(ann) => { const type = ann().type; + const isMask = type === "mask"; const maskType = () => ann().maskType ?? "blur"; const maskLevel = () => ann().maskLevel ?? 16; return (
- +
{type === "text" ? "Color" : "Stroke"} @@ -47,7 +48,7 @@ export function AnnotationConfig() {
- +
Width {ann().strokeWidth}px @@ -76,7 +77,7 @@ export function AnnotationConfig() {
- +
Opacity {Math.round(ann().opacity * 100)}% diff --git a/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx b/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx index 65858297e0..e089bfbca1 100644 --- a/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx +++ b/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx @@ -80,6 +80,48 @@ export function ExportDialog() { } }; + 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 applyMaskAnnotations = ( ctx: CanvasRenderingContext2D, source: HTMLCanvasElement, @@ -136,23 +178,7 @@ export function ExportDialog() { continue; } - ctx.save(); - ctx.beginPath(); - ctx.rect(startX, startY, regionWidth, regionHeight); - ctx.clip(); - ctx.filter = `blur(${level}px)`; - ctx.drawImage( - source, - startX, - startY, - regionWidth, - regionHeight, - startX, - startY, - regionWidth, - regionHeight, - ); - ctx.restore(); + blurRegion(ctx, source, startX, startY, regionWidth, regionHeight, level); } ctx.filter = "none"; }; diff --git a/apps/desktop/src/routes/screenshot-editor/Preview.tsx b/apps/desktop/src/routes/screenshot-editor/Preview.tsx index a0362d96dc..f8086e3af0 100644 --- a/apps/desktop/src/routes/screenshot-editor/Preview.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Preview.tsx @@ -217,6 +217,48 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { 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; @@ -308,23 +350,15 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { continue; } - ctx.save(); - ctx.beginPath(); - ctx.rect(startX, startY, regionWidth, regionHeight); - ctx.clip(); - ctx.filter = `blur(${level}px)`; - ctx.drawImage( + blurRegion( + ctx, source, startX, startY, regionWidth, regionHeight, - startX, - startY, - regionWidth, - regionHeight, + level, ); - ctx.restore(); } ctx.filter = "none"; From 1996ab6284cb45d65d05df1898ee8ff5b7708890 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:28:16 +0000 Subject: [PATCH 14/44] Implement Windows screenshot capture support --- apps/desktop/src-tauri/src/recording.rs | 22 +++- crates/enc-mediafoundation/src/video/h264.rs | 4 +- crates/recording/src/screenshot.rs | 111 ++++++++++++++++++- 3 files changed, 126 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 29dbe2afd7..b3a87ceb87 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1031,6 +1031,7 @@ pub async fn take_screenshot( use crate::NewScreenshotAdded; use crate::notifications; use cap_recording::screenshot::capture_screenshot; + use image::ImageEncoder; let image = capture_screenshot(target) .await @@ -1053,9 +1054,24 @@ pub async fn take_screenshot( let image_filename = "original.png"; let image_path = cap_dir.join(image_filename); - image - .save_with_format(&image_path, image::ImageFormat::Png) - .map_err(|e| format!("Failed to save screenshot: {e}"))?; + 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); diff --git a/crates/enc-mediafoundation/src/video/h264.rs b/crates/enc-mediafoundation/src/video/h264.rs index 266c117d89..17d6b3b8c2 100644 --- a/crates/enc-mediafoundation/src/video/h264.rs +++ b/crates/enc-mediafoundation/src/video/h264.rs @@ -11,7 +11,7 @@ use windows::{ Foundation::TimeSpan, Graphics::SizeInt32, Win32::{ - Foundation::E_NOTIMPL, + Foundation::{E_FAIL, E_NOTIMPL}, Graphics::{ Direct3D11::{ID3D11Device, ID3D11Texture2D}, Dxgi::Common::{DXGI_FORMAT, DXGI_FORMAT_NV12}, @@ -449,7 +449,7 @@ impl H264Encoder { consecutive_empty_samples += 1; if consecutive_empty_samples > MAX_CONSECUTIVE_EMPTY_SAMPLES { return Err(windows::core::Error::new( - windows::core::HRESULT(0), + E_FAIL, "Too many consecutive empty samples", )); } diff --git a/crates/recording/src/screenshot.rs b/crates/recording/src/screenshot.rs index e6c48b012f..85d154852b 100644 --- a/crates/recording/src/screenshot.rs +++ b/crates/recording/src/screenshot.rs @@ -1,11 +1,12 @@ use crate::sources::screen_capture::ScreenCaptureTarget; use anyhow::{Context, anyhow}; use image::RgbImage; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(target_os = "macos")] use scap_ffmpeg::AsFFmpeg; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::oneshot; +#[cfg(target_os = "macos")] use tracing::error; #[cfg(target_os = "macos")] @@ -137,10 +138,85 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { + let display = scap_targets::Display::from_id(&id) + .ok_or_else(|| anyhow!("Display not found"))?; + display + .raw_handle() + .try_as_capture_item() + .map_err(|e| anyhow!("Failed to get capture item: {e:?}"))? + } + ScreenCaptureTarget::Window { id } => { + let window = scap_targets::Window::from_id(&id) + .ok_or_else(|| anyhow!("Window not found"))?; + window + .raw_handle() + .try_as_capture_item() + .map_err(|e| anyhow!("Failed to get capture item: {e:?}"))? + } + ScreenCaptureTarget::Area { screen, .. } => { + let display = scap_targets::Display::from_id(&screen) + .ok_or_else(|| anyhow!("Display not found"))?; + display + .raw_handle() + .try_as_capture_item() + .map_err(|e| anyhow!("Failed to get capture item: {e:?}"))? + } + }; + + let settings = Settings { + is_cursor_capture_enabled: Some(true), + pixel_format: scap_direct3d::PixelFormat::R8G8B8A8Unorm, + ..Default::default() + }; + + Capturer::new( + item, + settings, + { + let tx = tx.clone(); + move |frame| { + if let Some(tx) = tx.lock().unwrap().take() { + let res = (|| { + let width = frame.width(); + let height = frame.height(); + let buffer = frame + .as_buffer() + .map_err(|e| anyhow!("Failed to get buffer: {e:?}"))?; + let data = buffer.data(); + let stride = buffer.stride() as usize; + let row_bytes = width as usize * 4; + + // R8G8B8A8Unorm is RGBA. + // We need to convert to RgbImage (3 channels). + let mut rgb_data = Vec::with_capacity((width * height * 3) as usize); + for y in 0..height as usize { + let row_start = y * stride; + let row_end = row_start + row_bytes; + if row_end > data.len() { + break; + } + let row = &data[row_start..row_end]; + for chunk in row.chunks_exact(4) { + rgb_data.push(chunk[0]); + rgb_data.push(chunk[1]); + rgb_data.push(chunk[2]); + } + } + + RgbImage::from_raw(width, height, rgb_data) + .ok_or_else(|| anyhow!("Failed to create RgbImage")) + })(); + let _ = tx.send(res); + } + Ok(()) + } + }, + || Ok(()), + None, + ) + .map_err(|e| anyhow!("Failed to create capturer: {e:?}"))? }; #[cfg(target_os = "macos")] @@ -149,6 +225,11 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result res, @@ -162,6 +243,11 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result { let mut scaler = ffmpeg::software::scaling::context::Context::get( frame.format(), From 61982eb243da16b1f9334e44f7457e7aa017af55 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:03:26 +0000 Subject: [PATCH 15/44] Add screenshot export improvements and new icon --- apps/desktop/src-tauri/src/lib.rs | 20 ++ .../src-tauri/src/screenshot_editor.rs | 5 +- apps/desktop/src/components/Mode.tsx | 5 +- apps/desktop/src/components/ModeSelect.tsx | 4 +- .../new-main/TargetMenuGrid.tsx | 47 +++- .../(window-chrome)/settings/screenshots.tsx | 30 +- apps/desktop/src/routes/mode-select.tsx | 25 +- .../routes/screenshot-editor/ExportDialog.tsx | 263 +----------------- .../src/routes/screenshot-editor/Header.tsx | 6 +- .../screenshot-editor/useScreenshotExport.ts | 250 +++++++++++++++++ apps/desktop/src/utils/tauri.ts | 3 + crates/enc-mediafoundation/src/mft.rs | 13 +- crates/rendering/src/lib.rs | 15 +- packages/ui-solid/icons/screenshot.svg | 1 + packages/ui-solid/src/auto-imports.d.ts | 3 + 15 files changed, 391 insertions(+), 299 deletions(-) create mode 100644 apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts create mode 100644 packages/ui-solid/icons/screenshot.svg diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d48ec03f9a..d3381b1735 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1113,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))] @@ -2248,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, diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 2a77d4b5bf..f8aa5a12df 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -213,12 +213,15 @@ impl ScreenshotEditorInstances { 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(width, height), + cap_project::XY::new(base_w, base_h), &cap_project::CursorEvents::default(), &segment_frames, ); diff --git a/apps/desktop/src/components/Mode.tsx b/apps/desktop/src/components/Mode.tsx index 7950c6fd38..37acdc2590 100644 --- a/apps/desktop/src/components/Mode.tsx +++ b/apps/desktop/src/components/Mode.tsx @@ -3,7 +3,6 @@ import { createSignal } from "solid-js"; import Tooltip from "~/components/Tooltip"; import { useRecordingOptions } from "~/routes/(window-chrome)/OptionsContext"; import { commands } from "~/utils/tauri"; -import IconCapImageFilled from "~icons/cap/image-filled"; const Mode = () => { const { rawOptions, setOptions } = useRecordingOptions(); @@ -81,7 +80,7 @@ const Mode = () => { : "bg-gray-3 hover:bg-gray-7" }`} > - +
)} @@ -124,7 +123,7 @@ const Mode = () => { : "bg-gray-3 hover:bg-gray-7" }`} > - +
)} diff --git a/apps/desktop/src/components/ModeSelect.tsx b/apps/desktop/src/components/ModeSelect.tsx index 8ca19afb1e..61f1d17a6f 100644 --- a/apps/desktop/src/components/ModeSelect.tsx +++ b/apps/desktop/src/components/ModeSelect.tsx @@ -73,7 +73,7 @@ const ModeSelect = (props: { onClose?: () => void; standalone?: boolean }) => { title: "Screenshot Mode", description: "Capture high-quality screenshots of your screen or specific windows. Annotate and share instantly.", - icon: IconCapCamera, + icon: IconCapScreenshot, }, ]; @@ -81,7 +81,7 @@ const ModeSelect = (props: { onClose?: () => void; standalone?: boolean }) => {
& { variant: "recording"; }; +type ScreenshotGridProps = BaseProps & { + variant: "screenshot"; +}; + type TargetMenuGridProps = | DisplayGridProps | WindowGridProps - | RecordingGridProps; + | RecordingGridProps + | ScreenshotGridProps; export default function TargetMenuGrid(props: TargetMenuGridProps) { const items = createMemo(() => props.targets ?? []); @@ -111,7 +117,9 @@ export default function TargetMenuGrid(props: TargetMenuGridProps) { ? "No displays found" : props.variant === "window" ? "No windows found" - : "No recordings found"; + : props.variant === "recording" + ? "No recordings found" + : "No screenshots found"; return (
+ + {(() => { + 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)/settings/screenshots.tsx b/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx index 998fdb22e0..58689f8078 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx @@ -27,11 +27,8 @@ import IconLucideCopy from "~icons/lucide/copy"; import IconLucideEdit from "~icons/lucide/edit"; import IconLucideFolder from "~icons/lucide/folder"; -type Screenshot = { - meta: RecordingMeta; - path: string; // png path - prettyName: string; - thumbnailPath: string; +type Screenshot = RecordingMeta & { + path: string; }; const Tabs: { id: string; label: string; icon?: JSX.Element }[] = [ @@ -45,22 +42,7 @@ const screenshotsQuery = queryOptions({ queryKey: ["screenshots"], queryFn: async () => { const result = await commands.listScreenshots().catch(() => [] as const); - - const screenshots = await Promise.all( - result.map(async (file) => { - const [path, meta] = file; - // path is the png path. - // thumbnail is the same as path for screenshots. - - return { - meta, - path, - prettyName: meta.pretty_name, - thumbnailPath: path, - }; - }), - ); - return screenshots; + return result.map(([path, meta]) => ({ ...meta, path })); }, reconcile: (old, n) => reconcile(n)(old), refetchInterval: 2000, @@ -193,14 +175,12 @@ function ScreenshotItem(props: { Screenshot thumbnail setImageExists(false)} />
- {props.screenshot.prettyName} + {props.screenshot.pretty_name}
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/ExportDialog.tsx b/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx index e089bfbca1..bc20446730 100644 --- a/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx +++ b/apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx @@ -1,264 +1,11 @@ import { Button } from "@cap/ui-solid"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { save } from "@tauri-apps/plugin-dialog"; -import { createSignal } from "solid-js"; -import toast from "solid-toast"; -import { commands } from "~/utils/tauri"; import IconCapCopy from "~icons/cap/copy"; import IconCapFile from "~icons/cap/file"; -import { type Annotation, useScreenshotEditorContext } from "./context"; -import { Dialog, DialogContent } from "./ui"; +import { DialogContent } from "./ui"; +import { useScreenshotExport } from "./useScreenshotExport"; export function ExportDialog() { - const { dialog, setDialog, path, latestFrame, annotations } = - useScreenshotEditorContext(); - const [exporting, setExporting] = createSignal(false); - - const drawAnnotations = ( - ctx: CanvasRenderingContext2D, - annotations: Annotation[], - ) => { - for (const ann of annotations) { - if (ann.type === "mask") continue; - ctx.save(); - ctx.globalAlpha = ann.opacity; - ctx.strokeStyle = ann.strokeColor; - ctx.lineWidth = ann.strokeWidth; - ctx.fillStyle = ann.fillColor; - - if (ann.type === "rectangle") { - if (ann.fillColor !== "transparent") { - ctx.fillRect(ann.x, ann.y, ann.width, ann.height); - } - ctx.strokeRect(ann.x, ann.y, ann.width, ann.height); - } else if (ann.type === "circle") { - ctx.beginPath(); - const cx = ann.x + ann.width / 2; - const cy = ann.y + ann.height / 2; - const rx = Math.abs(ann.width / 2); - const ry = Math.abs(ann.height / 2); - ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI); - if (ann.fillColor !== "transparent") { - ctx.fill(); - } - ctx.stroke(); - } else if (ann.type === "arrow") { - ctx.beginPath(); - const x1 = ann.x; - const y1 = ann.y; - const x2 = ann.x + ann.width; - const y2 = ann.y + ann.height; - - // Line - ctx.moveTo(x1, y1); - ctx.lineTo(x2, y2); - ctx.stroke(); - - // Arrowhead - const angle = Math.atan2(y2 - y1, x2 - x1); - const headLen = 10 + ann.strokeWidth; // scale with stroke? - ctx.beginPath(); - ctx.moveTo(x2, y2); - ctx.lineTo( - x2 - headLen * Math.cos(angle - Math.PI / 6), - y2 - headLen * Math.sin(angle - Math.PI / 6), - ); - ctx.lineTo( - x2 - headLen * Math.cos(angle + Math.PI / 6), - y2 - headLen * Math.sin(angle + Math.PI / 6), - ); - ctx.lineTo(x2, y2); - ctx.fillStyle = ann.strokeColor; - ctx.fill(); - } else if (ann.type === "text" && ann.text) { - ctx.fillStyle = ann.strokeColor; // Text uses stroke color - ctx.font = `${ann.height}px sans-serif`; - ctx.fillText(ann.text, ann.x, ann.y + ann.height); // text baseline bottomish - } - - ctx.restore(); - } - }; - - 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 applyMaskAnnotations = ( - ctx: CanvasRenderingContext2D, - source: HTMLCanvasElement, - annotations: Annotation[], - ) => { - for (const ann of annotations) { - if (ann.type !== "mask") continue; - - const startX = Math.max(0, Math.min(ann.x, ann.x + ann.width)); - const startY = Math.max(0, Math.min(ann.y, ann.y + ann.height)); - const endX = Math.min(source.width, Math.max(ann.x, ann.x + ann.width)); - const endY = Math.min(source.height, Math.max(ann.y, ann.y + ann.height)); - - const regionWidth = endX - startX; - const regionHeight = endY - startY; - if (regionWidth <= 0 || regionHeight <= 0) continue; - - const level = Math.max(1, ann.maskLevel ?? 16); - const type = ann.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, - ); - const previousSmoothing = ctx.imageSmoothingEnabled; - ctx.imageSmoothingEnabled = false; - ctx.drawImage( - temp, - 0, - 0, - temp.width, - temp.height, - startX, - startY, - regionWidth, - regionHeight, - ); - ctx.imageSmoothingEnabled = previousSmoothing; - continue; - } - - blurRegion(ctx, source, startX, startY, regionWidth, regionHeight, level); - } - ctx.filter = "none"; - }; - - const exportImage = async (destination: "file" | "clipboard") => { - setExporting(true); - try { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("Could not get canvas context"); - - const frame = latestFrame(); - if (frame) { - canvas.width = frame.width; - canvas.height = frame.data.height; - ctx.putImageData(frame.data, 0, 0); - } else { - // Fallback to loading file - const img = new Image(); - img.src = convertFileSrc(path); - await new Promise((resolve, reject) => { - img.onload = resolve; - img.onerror = reject; - }); - canvas.width = img.width; - canvas.height = img.height; - ctx.drawImage(img, 0, 0); - } - - const sourceCanvas = document.createElement("canvas"); - sourceCanvas.width = canvas.width; - sourceCanvas.height = canvas.height; - const sourceCtx = sourceCanvas.getContext("2d"); - if (!sourceCtx) throw new Error("Could not get source canvas context"); - sourceCtx.drawImage(canvas, 0, 0); - - applyMaskAnnotations(ctx, sourceCanvas, annotations); - drawAnnotations(ctx, annotations); - - // 3. Export - const blob = await new Promise((resolve) => - canvas.toBlob(resolve, "image/png"), - ); - if (!blob) throw new Error("Failed to create blob"); - - const buffer = await blob.arrayBuffer(); - const uint8Array = new Uint8Array(buffer); - - if (destination === "file") { - const savePath = await save({ - filters: [{ name: "PNG Image", extensions: ["png"] }], - defaultPath: "screenshot.png", - }); - if (savePath) { - await commands.writeFile(savePath, Array.from(uint8Array)); - toast.success("Screenshot saved!"); - } - } else { - // Copy to clipboard - // We need a command for this as web API might be limited - // commands.copyImageToClipboard(uint8Array)? - // For now, let's use the existing command if it supports data - // commands.copyScreenshotToClipboard(path) copies the file at path. - // If we want to copy the *edited* image, we need to save it to a temp file first or send bytes. - - // Fallback to copying the original file for now - await commands.copyScreenshotToClipboard(path); - toast.success( - "Original screenshot copied to clipboard (editing export WIP)", - ); - } - - setDialog({ ...dialog(), open: false }); - } catch (err) { - console.error(err); - toast.error("Failed to export"); - } finally { - setExporting(false); - } - }; + const { exportImage, isExporting } = useScreenshotExport(); return ( exportImage("file")} - disabled={exporting()} + disabled={isExporting()} > Save to File @@ -283,7 +30,7 @@ export function ExportDialog() { variant="gray" class="flex-1 flex gap-2 items-center justify-center h-12" onClick={() => exportImage("clipboard")} - disabled={exporting()} + disabled={isExporting()} > Copy to Clipboard diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx index 46c75070c8..4b2db39bde 100644 --- a/apps/desktop/src/routes/screenshot-editor/Header.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -21,6 +21,7 @@ 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"; @@ -40,6 +41,8 @@ export function Header() { const { path, setDialog, project, latestFrame } = useScreenshotEditorContext(); + const { exportImage, isExporting } = useScreenshotExport(); + const cropDialogHandler = () => { const frame = latestFrame(); setDialog({ @@ -93,9 +96,10 @@ export function Header() { { - commands.copyScreenshotToClipboard(path); + exportImage("clipboard"); }} tooltipText="Copy to Clipboard" + disabled={isExporting()} leftIcon={} /> diff --git a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts new file mode 100644 index 0000000000..354748b7ab --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts @@ -0,0 +1,250 @@ +import { convertFileSrc } from "@tauri-apps/api/core"; +import { save } from "@tauri-apps/plugin-dialog"; +import { createSignal } from "solid-js"; +import toast from "solid-toast"; +import { commands } from "~/utils/tauri"; +import { type Annotation, useScreenshotEditorContext } from "./context"; + +export function useScreenshotExport() { + const { path, latestFrame, annotations } = useScreenshotEditorContext(); + const [isExporting, setIsExporting] = createSignal(false); + + const drawAnnotations = ( + ctx: CanvasRenderingContext2D, + annotations: Annotation[], + ) => { + for (const ann of annotations) { + if (ann.type === "mask") continue; + ctx.save(); + ctx.globalAlpha = ann.opacity; + ctx.strokeStyle = ann.strokeColor; + ctx.lineWidth = ann.strokeWidth; + ctx.fillStyle = ann.fillColor; + + if (ann.type === "rectangle") { + if (ann.fillColor !== "transparent") { + ctx.fillRect(ann.x, ann.y, ann.width, ann.height); + } + ctx.strokeRect(ann.x, ann.y, ann.width, ann.height); + } else if (ann.type === "circle") { + ctx.beginPath(); + const cx = ann.x + ann.width / 2; + const cy = ann.y + ann.height / 2; + const rx = Math.abs(ann.width / 2); + const ry = Math.abs(ann.height / 2); + ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI); + if (ann.fillColor !== "transparent") { + ctx.fill(); + } + ctx.stroke(); + } else if (ann.type === "arrow") { + ctx.beginPath(); + const x1 = ann.x; + const y1 = ann.y; + const x2 = ann.x + ann.width; + const y2 = ann.y + ann.height; + + // Line + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + + // Arrowhead + const angle = Math.atan2(y2 - y1, x2 - x1); + const headLen = 10 + ann.strokeWidth; // scale with stroke? + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo( + x2 - headLen * Math.cos(angle - Math.PI / 6), + y2 - headLen * Math.sin(angle - Math.PI / 6), + ); + ctx.lineTo( + x2 - headLen * Math.cos(angle + Math.PI / 6), + y2 - headLen * Math.sin(angle + Math.PI / 6), + ); + ctx.lineTo(x2, y2); + ctx.fillStyle = ann.strokeColor; + ctx.fill(); + } else if (ann.type === "text" && ann.text) { + ctx.fillStyle = ann.strokeColor; // Text uses stroke color + ctx.font = `${ann.height}px sans-serif`; + ctx.fillText(ann.text, ann.x, ann.y + ann.height); // text baseline bottomish + } + + ctx.restore(); + } + }; + + 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 applyMaskAnnotations = ( + ctx: CanvasRenderingContext2D, + source: HTMLCanvasElement, + annotations: Annotation[], + ) => { + for (const ann of annotations) { + if (ann.type !== "mask") continue; + + const startX = Math.max(0, Math.min(ann.x, ann.x + ann.width)); + const startY = Math.max(0, Math.min(ann.y, ann.y + ann.height)); + const endX = Math.min(source.width, Math.max(ann.x, ann.x + ann.width)); + const endY = Math.min(source.height, Math.max(ann.y, ann.y + ann.height)); + + const regionWidth = endX - startX; + const regionHeight = endY - startY; + if (regionWidth <= 0 || regionHeight <= 0) continue; + + const level = Math.max(1, ann.maskLevel ?? 16); + const type = ann.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, + ); + const previousSmoothing = ctx.imageSmoothingEnabled; + ctx.imageSmoothingEnabled = false; + ctx.drawImage( + temp, + 0, + 0, + temp.width, + temp.height, + startX, + startY, + regionWidth, + regionHeight, + ); + ctx.imageSmoothingEnabled = previousSmoothing; + continue; + } + + blurRegion(ctx, source, startX, startY, regionWidth, regionHeight, level); + } + ctx.filter = "none"; + }; + + const exportImage = async (destination: "file" | "clipboard") => { + setIsExporting(true); + try { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Could not get canvas context"); + + const frame = latestFrame(); + if (frame) { + canvas.width = frame.width; + canvas.height = frame.data.height; + ctx.putImageData(frame.data, 0, 0); + } else { + // Fallback to loading file + const img = new Image(); + img.src = convertFileSrc(path); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + }); + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + } + + const sourceCanvas = document.createElement("canvas"); + sourceCanvas.width = canvas.width; + sourceCanvas.height = canvas.height; + const sourceCtx = sourceCanvas.getContext("2d"); + if (!sourceCtx) throw new Error("Could not get source canvas context"); + sourceCtx.drawImage(canvas, 0, 0); + + applyMaskAnnotations(ctx, sourceCanvas, annotations); + drawAnnotations(ctx, annotations); + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png"), + ); + if (!blob) throw new Error("Failed to create blob"); + + const buffer = await blob.arrayBuffer(); + const uint8Array = new Uint8Array(buffer); + + if (destination === "file") { + const savePath = await save({ + filters: [{ name: "PNG Image", extensions: ["png"] }], + defaultPath: "screenshot.png", + }); + if (savePath) { + await commands.writeFile(savePath, Array.from(uint8Array)); + toast.success("Screenshot saved!"); + setDialog({ ...dialog(), open: false }); + } + } else { + // Use the new copyImageToClipboard command + // @ts-ignore - commands type might not be updated yet + await commands.copyImageToClipboard(Array.from(uint8Array)); + toast.success("Screenshot copied to clipboard!"); + setDialog({ ...dialog(), open: false }); + } + } catch (err) { + console.error(err); + toast.error("Failed to export"); + } finally { + setIsExporting(false); + } + }; + + return { exportImage, isExporting }; +} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 918b3898e4..e0e2b94359 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -89,6 +89,9 @@ async copyVideoToClipboard(path: string) : Promise { async copyScreenshotToClipboard(path: string) : Promise { return await TAURI_INVOKE("copy_screenshot_to_clipboard", { path }); }, +async copyImageToClipboard(data: number[]) : Promise { + return await TAURI_INVOKE("copy_image_to_clipboard", { data }); +}, async openFilePath(path: string) : Promise { return await TAURI_INVOKE("open_file_path", { path }); }, diff --git a/crates/enc-mediafoundation/src/mft.rs b/crates/enc-mediafoundation/src/mft.rs index 808a5cab67..530acccab1 100644 --- a/crates/enc-mediafoundation/src/mft.rs +++ b/crates/enc-mediafoundation/src/mft.rs @@ -21,10 +21,21 @@ pub struct EncoderDevice { impl EncoderDevice { pub fn enumerate(major_type: GUID, subtype: GUID) -> Result> { - Self::enumerate_with_flags( + let devices = Self::enumerate_with_flags( major_type, subtype, MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_TRANSCODE_ONLY | MFT_ENUM_FLAG_SORTANDFILTER, + )?; + + if !devices.is_empty() { + return Ok(devices); + } + + // Fallback to software implementation if hardware encoding is not available + Self::enumerate_with_flags( + major_type, + subtype, + MFT_ENUM_FLAG_TRANSCODE_ONLY | MFT_ENUM_FLAG_SORTANDFILTER, ) } diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 4cd230eeda..4f2ad9fc1d 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -691,15 +691,14 @@ impl ProjectUniforms { basis as f64 * padding_factor } - pub fn get_output_size( + pub fn get_base_size( options: &RenderOptions, project: &ProjectConfiguration, - resolution_base: XY, ) -> (u32, u32) { let crop = Self::get_crop(options, project); let crop_aspect = crop.aspect_ratio(); - let (base_width, base_height) = match &project.aspect_ratio { + match &project.aspect_ratio { None => { let padding_basis = u32::max(crop.size.x, crop.size.y) as f64; let padding = @@ -744,7 +743,15 @@ impl ProjectUniforms { (crop.size.x, ((crop.size.x as f32 * 4.0 / 3.0) as u32)) } } - }; + } + } + + pub fn get_output_size( + options: &RenderOptions, + project: &ProjectConfiguration, + resolution_base: XY, + ) -> (u32, u32) { + let (base_width, base_height) = Self::get_base_size(options, project); let width_scale = resolution_base.x as f32 / base_width as f32; let height_scale = resolution_base.y as f32 / base_height as f32; diff --git a/packages/ui-solid/icons/screenshot.svg b/packages/ui-solid/icons/screenshot.svg new file mode 100644 index 0000000000..f5a9956bbd --- /dev/null +++ b/packages/ui-solid/icons/screenshot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 6bf33b06f4..930b3ac259 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -30,6 +30,7 @@ declare global { const IconCapGear: typeof import('~icons/cap/gear.jsx')['default'] const IconCapHotkeys: typeof import('~icons/cap/hotkeys.jsx')['default'] const IconCapImage: typeof import('~icons/cap/image.jsx')['default'] + const IconCapImageFilled: typeof import('~icons/cap/image-filled.jsx')['default'] const IconCapInfo: typeof import('~icons/cap/info.jsx')['default'] const IconCapInstant: typeof import('~icons/cap/instant.jsx')['default'] const IconCapLayout: typeof import('~icons/cap/layout.jsx')['default'] @@ -52,6 +53,8 @@ declare global { const IconCapRedo: typeof import('~icons/cap/redo.jsx')['default'] const IconCapRestart: typeof import('~icons/cap/restart.jsx')['default'] const IconCapScissors: typeof import('~icons/cap/scissors.jsx')['default'] + const IconCapScreen: typeof import('~icons/cap/screen.jsx')['default'] + const IconCapScreenshot: typeof import('~icons/cap/screenshot.jsx')['default'] const IconCapSettings: typeof import('~icons/cap/settings.jsx')['default'] const IconCapShadow: typeof import('~icons/cap/shadow.jsx')['default'] const IconCapSquare: typeof import('~icons/cap/square.jsx')['default'] From af1cc60d58f0b36ee1512eacb9c9ef7d051d58f2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:45:45 +0530 Subject: [PATCH 16/44] feat: Screenshots V1 - Enhance screenshot editor with mask tool and export cancel --- apps/desktop/src-tauri/src/export.rs | 20 +- .../src-tauri/src/screenshot_editor.rs | 12 - apps/desktop/src/entry-server.tsx | 7 + .../routes/(window-chrome)/new-main/index.tsx | 17 +- apps/desktop/src/routes/camera.tsx | 12 +- .../src/routes/editor/ExportDialog.tsx | 96 +++++- .../screenshot-editor/AnnotationConfig.tsx | 285 ++++++++--------- .../screenshot-editor/AnnotationLayer.tsx | 290 ++++++++++++++---- .../screenshot-editor/AnnotationTools.tsx | 71 +++-- .../src/routes/screenshot-editor/Editor.tsx | 117 +++++-- .../routes/screenshot-editor/ExportDialog.tsx | 42 --- .../src/routes/screenshot-editor/Header.tsx | 19 +- .../screenshot-editor/PresetsDropdown.tsx | 2 +- .../src/routes/screenshot-editor/Preview.tsx | 91 +++++- .../src/routes/screenshot-editor/arrow.ts | 30 ++ .../src/routes/screenshot-editor/context.tsx | 3 +- .../popovers/AnnotationPopover.tsx | 5 +- .../popovers/BackgroundSettingsPopover.tsx | 2 +- .../popovers/BorderPopover.tsx | 4 +- .../popovers/PaddingPopover.tsx | 1 + .../screenshot-editor/useScreenshotExport.ts | 108 +++++-- .../src/routes/target-select-overlay.tsx | 8 +- apps/desktop/src/styles/theme.css | 1 + crates/export/src/gif.rs | 6 +- crates/export/src/mp4.rs | 6 +- crates/recording/src/screenshot.rs | 10 +- crates/rendering/src/lib.rs | 5 +- 27 files changed, 830 insertions(+), 440 deletions(-) delete mode 100644 apps/desktop/src/routes/screenshot-editor/ExportDialog.tsx create mode 100644 apps/desktop/src/routes/screenshot-editor/arrow.ts diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index 44097fa772..9b1d1c461f 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -50,10 +50,12 @@ pub async fn export_video( settings .export(exporter_base, move |frame_index| { // Ensure progress never exceeds total frames - let _ = progress.send(FramesRendered { - rendered_count: (frame_index + 1).min(total_frames), - total_frames, - }); + progress + .send(FramesRendered { + rendered_count: (frame_index + 1).min(total_frames), + total_frames, + }) + .is_ok() }) .await } @@ -61,10 +63,12 @@ pub async fn export_video( settings .export(exporter_base, move |frame_index| { // Ensure progress never exceeds total frames - let _ = progress.send(FramesRendered { - rendered_count: (frame_index + 1).min(total_frames), - total_frames, - }); + progress + .send(FramesRendered { + rendered_count: (frame_index + 1).min(total_frames), + total_frames, + }) + .is_ok() }) .await } diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index f8aa5a12df..6971872a92 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -194,14 +194,9 @@ impl ScreenshotEditorInstances { 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(), @@ -237,10 +232,6 @@ impl ScreenshotEditorInstances { 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, @@ -254,13 +245,10 @@ impl ScreenshotEditorInstances { } // 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"); } }); diff --git a/apps/desktop/src/entry-server.tsx b/apps/desktop/src/entry-server.tsx index 2d450c0233..f9f4ae475f 100644 --- a/apps/desktop/src/entry-server.tsx +++ b/apps/desktop/src/entry-server.tsx @@ -9,6 +9,13 @@ export default createHandler(() => ( +