diff --git a/src/main/capture/compositor.ts b/src/main/capture/compositor.ts index 63e1f86..82f2a8d 100644 --- a/src/main/capture/compositor.ts +++ b/src/main/capture/compositor.ts @@ -3,11 +3,27 @@ import { readFile, writeFile } from 'node:fs/promises'; import logger from '@main/logger'; export interface CompositeBackground { - type: 'image' | 'color'; - /** When `type === 'image'`, absolute path. When `type === 'color'`, hex like `#0f172a`. */ + type: 'image' | 'color' | 'gradient'; + /** + * - `color`: hex like `#0f172a` + * - `image`: absolute path + * - `gradient`: any valid CSS background-image value, e.g. + * `linear-gradient(135deg, #ff6b6b, #f7931e)` + */ value: string; } +export type CompositeAlignment = + | 'top-left' + | 'top-center' + | 'top-right' + | 'center-left' + | 'center' + | 'center-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + export interface CompositeOptions { /** Path to the raw window screenshot from `screencapture -W -o`. */ inputPath: string; @@ -17,10 +33,36 @@ export interface CompositeOptions { background: CompositeBackground; /** DIPs of empty space between the window edge and the canvas edge. */ paddingPx: number; + /** Drop-shadow strength in DIPs. Default 30. Set 0 for no shadow. */ + shadowPx?: number; + /** Border-radius in DIPs applied to the captured image. Default 0 (use the image's natural alpha shape). */ + cornersPx?: number; + /** Where the captured image sits within the canvas. Default `center`. */ + alignment?: CompositeAlignment; } const COMPOSITE_TIMEOUT_MS = 8_000; -const SHADOW_PX = 30; // dropped behind the window — visually closer to CleanShot's default +const DEFAULT_SHADOW_PX = 30; + +/** + * Alignment slides the image toward an edge by *doubling padding on the + * opposite side* (rather than introducing a separate aspect-ratio + * concept). e.g. `bottom-right` → top + left get 2× padding. + */ +export function paddingForAlignment( + alignment: CompositeAlignment, + base: number, +): { top: number; right: number; bottom: number; left: number } { + let top = base; + let right = base; + let bottom = base; + let left = base; + if (alignment.startsWith('top')) bottom = base * 2; + else if (alignment.startsWith('bottom')) top = base * 2; + if (alignment.endsWith('right')) left = base * 2; + else if (alignment.endsWith('left')) right = base * 2; + return { top, right, bottom, left }; +} /** * Composite a captured window onto a chosen background using a hidden @@ -38,27 +80,23 @@ export async function compositeWindowOnBackground(opts: CompositeOptions): Promi throw new Error(`compositor: empty image at ${opts.inputPath}`); } const winSize = inputImg.getSize(); - const outW = winSize.width + 2 * opts.paddingPx; - const outH = winSize.height + 2 * opts.paddingPx; + const padding = paddingForAlignment(opts.alignment ?? 'center', opts.paddingPx); + const outW = winSize.width + padding.left + padding.right; + const outH = winSize.height + padding.top + padding.bottom; // Load both images as data URLs so the renderer can show them without // worrying about file:// + CSP. const winDataUrl = `data:image/png;base64,${(await readFile(opts.inputPath)).toString('base64')}`; - let bgCss: string; - if (opts.background.type === 'color') { - bgCss = opts.background.value; - } else { - const bgBuf = await readFile(opts.background.value); - const bgB64 = bgBuf.toString('base64'); - // Trust file extension — these come from the OS file dialog. - const ext = opts.background.value.split('.').pop()?.toLowerCase() ?? 'png'; - const mime = ext === 'jpg' || ext === 'jpeg' ? 'jpeg' : ext === 'heic' ? 'heic' : 'png'; - bgCss = `center / cover no-repeat url("data:image/${mime};base64,${bgB64}")`; - } + const bgCss = await resolveBackgroundCss(opts.background); + + const shadowPx = opts.shadowPx ?? DEFAULT_SHADOW_PX; + const cornersPx = opts.cornersPx ?? 0; const html = buildHtml({ bgCss, - paddingPx: opts.paddingPx, + padding, + shadowPx, + cornersPx, winSize, outSize: { w: outW, h: outH }, winDataUrl, @@ -98,7 +136,11 @@ export async function compositeWindowOnBackground(opts: CompositeOptions): Promi input: opts.inputPath, output: opts.outputPath, bgType: opts.background.type, - paddingPx: opts.paddingPx, + basePaddingPx: opts.paddingPx, + padding, + shadowPx, + cornersPx, + alignment: opts.alignment ?? 'center', outSize: `${outW}x${outH}`, }); } finally { @@ -106,6 +148,21 @@ export async function compositeWindowOnBackground(opts: CompositeOptions): Promi } } +async function resolveBackgroundCss(bg: CompositeBackground): Promise { + if (bg.type === 'color') { + return bg.value; + } + if (bg.type === 'gradient') { + return bg.value; + } + // type === 'image' + const bgBuf = await readFile(bg.value); + const bgB64 = bgBuf.toString('base64'); + const ext = bg.value.split('.').pop()?.toLowerCase() ?? 'png'; + const mime = ext === 'jpg' || ext === 'jpeg' ? 'jpeg' : ext === 'heic' ? 'heic' : 'png'; + return `center / cover no-repeat url("data:image/${mime};base64,${bgB64}")`; +} + function loadWithTimeout(win: BrowserWindow, html: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout( @@ -126,11 +183,26 @@ function loadWithTimeout(win: BrowserWindow, html: string): Promise { function buildHtml(args: { bgCss: string; - paddingPx: number; + padding: { top: number; right: number; bottom: number; left: number }; + shadowPx: number; + cornersPx: number; winSize: { width: number; height: number }; outSize: { w: number; h: number }; winDataUrl: string; }): string { + // For solid colors (and gradients which are also background-image), we + // need to use `background:` shorthand. CSS handles both. + const shadow = + args.shadowPx > 0 + ? `filter: drop-shadow(0 ${Math.round(args.shadowPx / 2)}px ${args.shadowPx}px rgba(0, 0, 0, 0.45));` + : ''; + // When the user picks rounded corners, wrap the image in a clipping div. + // The captured PNG's own alpha (rounded macOS window corners) sits + // inside the rounded clip — for area screenshots this is what produces + // the visible rounded corner. + const clipStyles = + args.cornersPx > 0 ? `border-radius: ${args.cornersPx}px; overflow: hidden; ${shadow}` : shadow; + return ` @@ -144,9 +216,13 @@ function buildHtml(args: { width: ${args.outSize.w}px; height: ${args.outSize.h}px; background: ${args.bgCss}; - display: flex; - align-items: center; - justify-content: center; + /* + * Padding handles alignment (asymmetric values shift the image toward + * one corner) so we leave display:block here — the .clip is + * absolutely positioned by the asymmetric padding. + */ + padding: ${args.padding.top}px ${args.padding.right}px ${args.padding.bottom}px ${args.padding.left}px; + box-sizing: border-box; } /* * The captured PNG already has the window's native rounded corners @@ -154,17 +230,21 @@ function buildHtml(args: { * shadow follows the real alpha shape — box-shadow on a rectangular * wrapper would draw a square shadow ignoring the corners. */ - img { - display: block; + .clip { width: ${args.winSize.width}px; height: ${args.winSize.height}px; - filter: drop-shadow(0 ${Math.round(SHADOW_PX / 2)}px ${SHADOW_PX}px rgba(0, 0, 0, 0.45)); + ${clipStyles} + } + img { + display: block; + width: 100%; + height: 100%; }
- +
`; diff --git a/src/main/capture/screenshot.ts b/src/main/capture/screenshot.ts index a18d59b..1d6d60d 100644 --- a/src/main/capture/screenshot.ts +++ b/src/main/capture/screenshot.ts @@ -4,68 +4,16 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { app, clipboard, nativeImage } from 'electron'; import logger from '@main/logger'; +import { buildArgs } from '@main/capture/screenshotArgs'; import { compositeWindowOnBackground } from '@main/capture/compositor'; import { pickRegion } from '@main/selection/overlay'; import { getPreferences } from '@main/storage/prefs'; import { insertCapture } from '@main/storage/db'; import { setDesktopIconsHidden } from '@main/system/desktopIcons'; -import type { - AppPreferences, - CaptureMode, - CaptureOptions, - CaptureResult, - SelectionRect, -} from '@shared/types'; +import type { AppPreferences, CaptureOptions, CaptureResult } from '@shared/types'; const SCREENCAPTURE_BIN = '/usr/sbin/screencapture'; -/** - * Build the argv for /usr/sbin/screencapture. - * - * Reference: `man screencapture`. Flags we use: - * -i interactive (mouse selection) - * -W start in window-selection mode (only with -i) - * -R rect x,y,w,h in DIPs (skips -i; we drove our own selection) - * -t format (png|jpg|...) - * -o do not include window shadow when in window mode - * -x do not play sound - * -T delay in seconds before capture (full-screen only) - */ -export function buildArgs( - mode: CaptureMode, - format: 'png' | 'jpg', - delaySeconds: number, - outFile: string, - silent: boolean, - region?: SelectionRect, -): string[] { - // -x silences the shutter sound; omit it when the user wants the sound. - const args: string[] = silent ? ['-x', '-t', format] : ['-t', format]; - - // When we already know the rect (from our overlay or "previous area"), - // -R replaces the interactive HUD entirely. - if (region) { - args.push('-R', `${region.x},${region.y},${region.width},${region.height}`); - args.push(outFile); - return args; - } - - switch (mode) { - case 'area': - args.push('-i'); - break; - case 'window': - args.push('-i', '-W', '-o'); - break; - case 'fullscreen': - if (delaySeconds > 0) args.push('-T', String(delaySeconds)); - break; - } - - args.push(outFile); - return args; -} - function defaultSaveDir(): string { return join(app.getPath('pictures'), 'Snapora'); } diff --git a/src/main/capture/screenshotArgs.ts b/src/main/capture/screenshotArgs.ts new file mode 100644 index 0000000..9b19b70 --- /dev/null +++ b/src/main/capture/screenshotArgs.ts @@ -0,0 +1,58 @@ +import type { CaptureMode, SelectionRect } from '@shared/types'; + +/** + * Build the argv for /usr/sbin/screencapture. + * + * Pure function — kept in its own module so the unit tests don't have to + * import `electron` (which screenshot.ts does for clipboard/nativeImage). + * + * Reference: `man screencapture`. Flags we use: + * -i interactive (mouse selection) + * -W start in window-selection mode (only with -i) + * -R rect x,y,w,h in pixels (skips -i; we drove our own selection) + * -t format (png|jpg|...) + * -o do not include window shadow when in window mode + * -x do not play sound + * -T delay in seconds before capture (full-screen only) + */ +export function buildArgs( + mode: CaptureMode, + format: 'png' | 'jpg', + delaySeconds: number, + outFile: string, + silent: boolean, + region?: SelectionRect, +): string[] { + // -x silences the shutter sound; omit it when the user wants the sound. + const args: string[] = silent ? ['-x', '-t', format] : ['-t', format]; + + // When we already know the rect (from our overlay or "previous area"), + // -R replaces the interactive HUD entirely. + // NOTE: `screencapture -R` requires integer pixel values. Passing decimals + // (e.g. CSS-pixel coords from a Retina overlay drag) makes the command + // silently fall back to a full-screen capture on macOS 14+. + if (region) { + const x = Math.round(region.x); + const y = Math.round(region.y); + const w = Math.round(region.width); + const h = Math.round(region.height); + args.push('-R', `${x},${y},${w},${h}`); + args.push(outFile); + return args; + } + + switch (mode) { + case 'area': + args.push('-i'); + break; + case 'window': + args.push('-i', '-W', '-o'); + break; + case 'fullscreen': + if (delaySeconds > 0) args.push('-T', String(delaySeconds)); + break; + } + + args.push(outFile); + return args; +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index ff07031..ec507d0 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -5,7 +5,11 @@ import { listPermissions, openSystemSettingsFor, requestPermission } from '@main import { syncLoginItem } from '@main/storage/loginItem'; import { getPreferences, setPreferences } from '@main/storage/prefs'; import { setDesktopIconsHidden } from '@main/system/desktopIcons'; -import { getCurrentEditorImageUrl } from '@main/windows/editor'; +import { + composeEditorImage, + getCurrentEditorImageUrl, + openFileInEditor, +} from '@main/windows/editor'; import { showHudWithImage } from '@main/windows/hud'; import { markFirstRunDone, relaunchApp } from '@main/windows/firstRun'; import { chooseSaveDirectory, chooseWallpaperImage } from '@main/windows/settings'; @@ -13,7 +17,7 @@ import { registerHistoryHandlers } from '@main/ipc/historyHandlers'; import { registerSelectionHandlers } from '@main/selection/overlay'; import { registerGlobalShortcuts } from '@main/shortcuts/index'; import { registerHudHandlers } from '@main/ipc/hudHandlers'; -import { IPC } from '@shared/ipc'; +import { IPC, type EditorBackgroundConfig } from '@shared/ipc'; import type { AppPreferences, CaptureOptions, Permission } from '@shared/types'; export function registerIpcHandlers(): void { @@ -58,6 +62,10 @@ export function registerIpcHandlers(): void { }); ipcMain.handle(IPC.editor.requestCurrent, () => getCurrentEditorImageUrl()); + ipcMain.handle(IPC.editor.compose, (_evt, config: EditorBackgroundConfig) => + composeEditorImage(config), + ); + ipcMain.handle(IPC.editor.openFile, () => openFileInEditor()); registerHudHandlers(); registerHistoryHandlers(); diff --git a/src/main/windows/editor.ts b/src/main/windows/editor.ts index 7e71ecd..4f769d8 100644 --- a/src/main/windows/editor.ts +++ b/src/main/windows/editor.ts @@ -1,11 +1,13 @@ -import { BrowserWindow, shell } from 'electron'; +import { BrowserWindow, dialog, shell } from 'electron'; import { join } from 'node:path'; import logger from '@main/logger'; +import { compositeWindowOnBackground } from '@main/capture/compositor'; import { toSnapUrl } from '@main/security/protocol'; -import { IPC } from '@shared/ipc'; +import { IPC, type EditorBackgroundConfig, type EditorComposeResult } from '@shared/ipc'; let editorWindow: BrowserWindow | null = null; let currentImageUrl: string | null = null; +let currentImagePath: string | null = null; /** Returns the most recent image URL pushed to the editor, or null. */ export function getCurrentEditorImageUrl(): string | null { @@ -78,6 +80,7 @@ export function openEditorEmpty(): void { export function showEditorWithImage(filePath: string): void { const win = getOrCreateEditorWindow(); + currentImagePath = filePath; // Convert to snap:// so the renderer can load the local file safely // (file:// is blocked by Electron's web-security from non-file origins). currentImageUrl = toSnapUrl(filePath); @@ -88,3 +91,77 @@ export function showEditorWithImage(filePath: string): void { win.show(); win.focus(); } + +/** + * Re-composite the current editor image with a new background config and + * replace the file in place. Returns a fresh snap:// URL with a cache-bust + * suffix so the renderer's reloads. + * + * If `config.type === 'none'`, the file is left untouched. + */ +export async function composeEditorImage( + config: EditorBackgroundConfig, +): Promise { + if (!currentImagePath || !currentImageUrl) { + throw new Error('editor: compose requested but no image is loaded'); + } + if (config.type === 'none') { + // Nothing to do — return the current URL with a fresh cache-bust so + // any renderer-side state updates still trigger an reload. + return { snapUrl: cacheBust(currentImageUrl) }; + } + + const fallbackHex = '#0f172a'; + await compositeWindowOnBackground({ + inputPath: currentImagePath, + outputPath: currentImagePath, // replace in place + background: + config.type === 'color' + ? { type: 'color', value: config.value ?? fallbackHex } + : config.type === 'gradient' + ? { type: 'gradient', value: config.value ?? fallbackHex } + : { type: 'image', value: config.value ?? '' }, + paddingPx: config.paddingPx, + shadowPx: config.shadowPx, + cornersPx: config.cornersPx, + alignment: config.alignment, + }); + + const fresh = cacheBust(toSnapUrl(currentImagePath)); + currentImageUrl = fresh; + logger.info('editor: composed', { + file: currentImagePath, + bg: config.type, + paddingPx: config.paddingPx, + shadowPx: config.shadowPx, + cornersPx: config.cornersPx, + alignment: config.alignment, + }); + return { snapUrl: fresh }; +} + +/** + * Pop a file dialog, load the picked image into the editor, and return + * its snap:// URL. Used by the empty-state "Open file…" button so users + * can edit existing screenshots without re-capturing. + */ +export async function openFileInEditor(): Promise { + const focused = BrowserWindow.getFocusedWindow(); + const result = await dialog.showOpenDialog(focused ?? new BrowserWindow({ show: false }), { + properties: ['openFile'], + title: 'Open image in editor', + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'heic', 'tif', 'tiff'] }], + }); + if (result.canceled || result.filePaths.length === 0) return null; + const path = result.filePaths[0]; + if (!path) return null; + showEditorWithImage(path); + return currentImageUrl; +} + +function cacheBust(url: string): string { + // Strip an existing `?v=...` then append a fresh one so the + // bypasses the snap:// scheme's cache. + const base = url.split('?')[0] ?? url; + return `${base}?v=${Date.now()}`; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 3e7e8b9..e4e2c28 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,7 +8,13 @@ import type { PermissionState, SelectionRect, } from '@shared/types'; -import type { HudCard, SelectionInitPayload, SnaporaApi } from '@shared/ipc'; +import type { + EditorBackgroundConfig, + EditorComposeResult, + HudCard, + SelectionInitPayload, + SnaporaApi, +} from '@shared/ipc'; const api: SnaporaApi = { capture: (options: CaptureOptions): Promise => @@ -54,6 +60,9 @@ const api: SnaporaApi = { return () => ipcRenderer.removeListener(IPC.editor.onImageReady, listener); }, requestCurrent: (): Promise => ipcRenderer.invoke(IPC.editor.requestCurrent), + compose: (config: EditorBackgroundConfig): Promise => + ipcRenderer.invoke(IPC.editor.compose, config), + openFile: (): Promise => ipcRenderer.invoke(IPC.editor.openFile), }, hud: { onStack: (handler: (cards: HudCard[]) => void) => { diff --git a/src/renderer/components/Editor.tsx b/src/renderer/components/Editor.tsx index 152b9f1..0cc4166 100644 --- a/src/renderer/components/Editor.tsx +++ b/src/renderer/components/Editor.tsx @@ -1,82 +1,589 @@ -import { Copy, Pencil, Save } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { WindowChrome, WindowFooter, WindowShell } from './Layout'; +import { Check, FolderOpen, ImageIcon, Loader2, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { WindowChrome, WindowShell } from './Layout'; import { Button } from './ui/button'; +import { cn } from '@renderer/lib/cn'; +import type { EditorAlignment, EditorBackgroundConfig } from '@shared/ipc'; /** - * v0.1 — placeholder editor that displays the captured image with a basic action bar. - * v0.3 — replaces the centered with a Konva-backed annotation canvas. + * Background tool. Pick a color / gradient / custom image to wrap around + * the captured screenshot, scrub padding / shadow / corners / alignment, + * click Done to re-composite. Live preview is HTML/CSS; the real + * composite happens in main via `webContents.capturePage` in + * `compositor.ts`. + * + * Out of scope here, slated for follow-up PRs: + * - Bundled wallpaper presets (need asset packaging) + * - Blurred presets (need backdrop-filter mechanism) + * - Aspect ratio selector + auto-balance + * - Annotation tools (Konva — v0.3) */ + +const PRESET_COLORS: string[] = [ + '#0f172a', // dark navy + '#ffffff', // white + '#dc2626', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#3b82f6', // blue + '#a855f7', // purple +]; + +interface GradientPreset { + id: string; + css: string; + swatch: string; // smaller version for the swatch UI +} + +const GRADIENT_PRESETS: GradientPreset[] = [ + { + id: 'sunset', + css: 'linear-gradient(135deg, #ff6b6b 0%, #f7931e 100%)', + swatch: 'linear-gradient(135deg, #ff6b6b, #f7931e)', + }, + { + id: 'ocean', + css: 'linear-gradient(135deg, #2193b0 0%, #6dd5ed 100%)', + swatch: 'linear-gradient(135deg, #2193b0, #6dd5ed)', + }, + { + id: 'mint', + css: 'linear-gradient(135deg, #43cea2 0%, #185a9d 100%)', + swatch: 'linear-gradient(135deg, #43cea2, #185a9d)', + }, + { + id: 'peach', + css: 'linear-gradient(135deg, #ffafbd 0%, #ffc3a0 100%)', + swatch: 'linear-gradient(135deg, #ffafbd, #ffc3a0)', + }, + { + id: 'violet', + css: 'linear-gradient(135deg, #614385 0%, #516395 100%)', + swatch: 'linear-gradient(135deg, #614385, #516395)', + }, + { + id: 'amber', + css: 'linear-gradient(135deg, #fc4a1a 0%, #f7b733 100%)', + swatch: 'linear-gradient(135deg, #fc4a1a, #f7b733)', + }, + { + id: 'rose', + css: 'linear-gradient(135deg, #ee9ca7 0%, #ffdde1 100%)', + swatch: 'linear-gradient(135deg, #ee9ca7, #ffdde1)', + }, + { + id: 'midnight', + css: 'linear-gradient(135deg, #232526 0%, #414345 100%)', + swatch: 'linear-gradient(135deg, #232526, #414345)', + }, + { + id: 'mango', + css: 'linear-gradient(135deg, #ffb75e 0%, #ed8f03 100%)', + swatch: 'linear-gradient(135deg, #ffb75e, #ed8f03)', + }, + { + id: 'lagoon', + css: 'linear-gradient(135deg, #43c6ac 0%, #f8ffae 100%)', + swatch: 'linear-gradient(135deg, #43c6ac, #f8ffae)', + }, +]; + +const ALIGNMENT_GRID: EditorAlignment[] = [ + 'top-left', + 'top-center', + 'top-right', + 'center-left', + 'center', + 'center-right', + 'bottom-left', + 'bottom-center', + 'bottom-right', +]; + +type BgKind = 'none' | 'color' | 'image' | 'gradient'; + +function alignmentToFlex(alignment: EditorAlignment): { + justify: string; + align: string; +} { + const justify = alignment.endsWith('right') + ? 'flex-end' + : alignment.endsWith('left') + ? 'flex-start' + : 'center'; + const align = alignment.startsWith('bottom') + ? 'flex-end' + : alignment.startsWith('top') + ? 'flex-start' + : 'center'; + return { justify, align }; +} + +function paddingForAlignment( + alignment: EditorAlignment, + base: number, +): { top: number; right: number; bottom: number; left: number } { + let top = base; + let right = base; + let bottom = base; + let left = base; + if (alignment.startsWith('top')) bottom = base * 2; + else if (alignment.startsWith('bottom')) top = base * 2; + if (alignment.endsWith('right')) left = base * 2; + else if (alignment.endsWith('left')) right = base * 2; + return { top, right, bottom, left }; +} + export function Editor() { - // Receives a snap:// URL from the main process; usable directly in . const [imageUrl, setImageUrl] = useState(null); + const [bgKind, setBgKind] = useState('none'); + const [color, setColor] = useState('#0f172a'); + const [gradient, setGradient] = useState(GRADIENT_PRESETS[0]?.css ?? ''); + const [imagePath, setImagePath] = useState(null); + const [paddingPx, setPaddingPx] = useState(64); + const [shadowPx, setShadowPx] = useState(30); + const [cornersPx, setCornersPx] = useState(0); + const [alignment, setAlignment] = useState('center'); + const [composing, setComposing] = useState(false); + const [toast, setToast] = useState(null); useEffect(() => { - // Subscribe first so we don't miss a push that arrives while we're requesting. - const off = window.snapora.editor.onImageReady((url: string) => setImageUrl(url)); - // Also pull the current image — covers the race where main pushed - // before this effect ran (e.g. first capture after window creation). + const off = window.snapora.editor.onImageReady((url) => setImageUrl(url)); void window.snapora.editor.requestCurrent().then((url) => { if (url) setImageUrl(url); }); return off; }, []); + const previewBg = useMemo(() => { + if (bgKind === 'color') return color; + if (bgKind === 'gradient') return gradient; + if (bgKind === 'image' && imagePath) + return `center / cover no-repeat url("file://${imagePath}")`; + return '#1a1a1a'; // editor canvas color when no bg picked + }, [bgKind, color, gradient, imagePath]); + const hasImage = imageUrl !== null; + const canCompose = hasImage && bgKind !== 'none' && (bgKind !== 'image' || !!imagePath); + + const previewPad = useMemo( + () => + bgKind === 'none' + ? { top: 0, right: 0, bottom: 0, left: 0 } + : paddingForAlignment(alignment, paddingPx), + [bgKind, alignment, paddingPx], + ); + + const previewFlex = useMemo(() => alignmentToFlex(alignment), [alignment]); + + const pickImage = async (): Promise => { + const path = await window.snapora.wallpaper.chooseImage(); + if (path) { + setImagePath(path); + setBgKind('image'); + } + }; + + const onOpenFile = async (): Promise => { + const url = await window.snapora.editor.openFile(); + if (url) setImageUrl(url); + }; + + const onDone = async (): Promise => { + if (!canCompose) return; + setComposing(true); + try { + const config: EditorBackgroundConfig = { + type: bgKind === 'color' ? 'color' : bgKind === 'gradient' ? 'gradient' : 'image', + value: bgKind === 'color' ? color : bgKind === 'gradient' ? gradient : (imagePath ?? ''), + paddingPx, + shadowPx, + cornersPx, + alignment, + }; + const result = await window.snapora.editor.compose(config); + setImageUrl(result.snapUrl); + setToast('Saved'); + setTimeout(() => setToast(null), 1200); + } catch (err) { + console.error('[editor] compose failed', err); + setToast('Compose failed'); + setTimeout(() => setToast(null), 1500); + } finally { + setComposing(false); + } + }; return ( - - - - + ) : null } /> -
+ +
{hasImage ? ( - captured setBgKind('none')} + onPickColor={(hex) => { + setColor(hex); + setBgKind('color'); + }} + onPickGradient={(css) => { + setGradient(css); + setBgKind('gradient'); + }} + onPickImage={() => void pickImage()} + onClearImage={() => { + setImagePath(null); + if (bgKind === 'image') setBgKind('none'); + }} + onPaddingChange={setPaddingPx} + onShadowChange={setShadowPx} + onCornersChange={setCornersPx} + onAlignmentChange={setAlignment} /> - ) : ( -
-

No capture yet

-

Press your hotkey to take a screenshot.

-
- )} -
- - Annotation tools coming in v0.3 — see ROADMAP.md - {hasImage ? PNG : null} - + ) : null} + +
+ {hasImage ? ( +
+
+ captured 0 ? cornersPx : undefined, + filter: + bgKind !== 'none' && shadowPx > 0 + ? `drop-shadow(0 ${Math.round(shadowPx / 2)}px ${shadowPx}px rgba(0,0,0,0.45))` + : undefined, + }} + /> +
+
+ ) : ( + void onOpenFile()} /> + )} + + {toast ? ( +
+ {toast} +
+ ) : null} +
+
); } -function shortPathFromSnapUrl(snapUrl: string): string { - // snap:///Users/.../Snapora%20foo.png → readable last few segments - try { - const path = decodeURIComponent(new URL(snapUrl).pathname); - const parts = path.split('/'); - if (parts.length <= 4) return path; - return `…/${parts.slice(-3).join('/')}`; - } catch { - return snapUrl; - } +function EmptyState({ onOpenFile }: { onOpenFile: () => void }) { + return ( +
+
+

No image loaded

+

+ Take a screenshot, or open an existing image to use the Background tool. +

+ +
+
+ ); +} + +interface SidebarProps { + bgKind: BgKind; + color: string; + gradient: string; + imagePath: string | null; + paddingPx: number; + shadowPx: number; + cornersPx: number; + alignment: EditorAlignment; + onClearBg: () => void; + onPickColor: (hex: string) => void; + onPickGradient: (css: string) => void; + onPickImage: () => void; + onClearImage: () => void; + onPaddingChange: (n: number) => void; + onShadowChange: (n: number) => void; + onCornersChange: (n: number) => void; + onAlignmentChange: (a: EditorAlignment) => void; +} + +function Sidebar(props: SidebarProps) { + const noBg = props.bgKind === 'none'; + + return ( + + ); +} + +function NumericRow({ + label, + value, + min, + max, + onChange, + disabled, +}: { + label: string; + value: number; + min: number; + max: number; + onChange: (n: number) => void; + disabled?: boolean; +}) { + return ( +
+ {label} +
+ onChange(Number(e.target.value))} + className="flex-1 accent-amber-400 disabled:opacity-50" + /> + {value} +
+
+ ); +} + +function AlignmentGrid({ + value, + onChange, + disabled, +}: { + value: EditorAlignment; + onChange: (a: EditorAlignment) => void; + disabled?: boolean; +}) { + return ( +
+ {ALIGNMENT_GRID.map((a) => { + const active = value === a; + return ( + + ); + })} +
+ ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); } diff --git a/src/renderer/components/Hud.tsx b/src/renderer/components/Hud.tsx index 0af692a..3c01ef1 100644 --- a/src/renderer/components/Hud.tsx +++ b/src/renderer/components/Hud.tsx @@ -152,14 +152,6 @@ function Card({ card }: { card: HudCard }) { - window.snapora.hud.openCardInEditor(card.id))} - > - - - {card.width && card.height ? (
{card.width} × {card.height} @@ -195,6 +187,13 @@ function Card({ card }: { card: HudCard }) { > Save + } + onClick={run('edit', () => window.snapora.hud.openCardInEditor(card.id))} + disabled={pendingAction === 'edit'} + > + Edit +
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 26cc2ce..d550f12 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -46,6 +46,45 @@ export interface HudCard { capturedAt: string; } +export type EditorAlignment = + | 'top-left' + | 'top-center' + | 'top-right' + | 'center-left' + | 'center' + | 'center-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + +/** + * Background config for the editor's "Background tool". Maps onto the + * shared `compositeWindowOnBackground` helper. + */ +export interface EditorBackgroundConfig { + /** `none` skips compositing — file stays as-is. */ + type: 'none' | 'color' | 'image' | 'gradient'; + /** + * - `color`: hex like `#0f172a` + * - `image`: absolute path + * - `gradient`: any valid CSS background-image (e.g. `linear-gradient(...)`) + */ + value?: string; + /** DIPs of background visible around the captured image. */ + paddingPx: number; + /** Drop-shadow strength in DIPs. 0 = no shadow. Default 30. */ + shadowPx?: number; + /** Border-radius in DIPs on the captured image. 0 = use natural alpha shape. */ + cornersPx?: number; + /** Where the captured image sits within the canvas. Default `center`. */ + alignment?: EditorAlignment; +} + +export interface EditorComposeResult { + /** Updated snap:// URL (cache-busted with `?v=`) for the renderer to reload. */ + snapUrl: string; +} + /** * Centralized IPC channel names. Both sides import from here so a typo is a compile error. */ @@ -81,6 +120,14 @@ export const IPC = { editor: { onImageReady: 'editor:image-ready', requestCurrent: 'editor:request-current', + /** + * Re-composite the current image with a new background config. + * Replaces the file in place and returns the new snap:// URL (with a + * cache-busting suffix) so the renderer can refresh its . + */ + compose: 'editor:compose', + /** Open a file dialog and load the picked image into the editor. */ + openFile: 'editor:open-file', }, hud: { /** main → renderer: a fresh card just landed; full stack is sent. */ @@ -155,6 +202,13 @@ export interface SnaporaApi { onImageReady(handler: (snapUrl: string) => void): () => void; /** Returns the most recent image URL the main process has shown, or null. */ requestCurrent(): Promise; + /** Re-composite the current image and replace it on disk. */ + compose(config: EditorBackgroundConfig): Promise; + /** + * Open a file dialog and load the picked image into the editor. + * Returns the new snap:// URL, or null if the user cancelled. + */ + openFile(): Promise; }; hud: { /** Subscribe to stack pushes. Handler receives the full updated stack. */ diff --git a/tests/unit/screenshot.test.ts b/tests/unit/screenshot.test.ts index 93c7955..45b33f5 100644 --- a/tests/unit/screenshot.test.ts +++ b/tests/unit/screenshot.test.ts @@ -1,14 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { buildArgs } from '@main/capture/screenshotArgs'; import { DEFAULT_PREFERENCES } from '@shared/types'; -/** - * The screenshot module imports from 'electron' (clipboard, nativeImage), - * which can't load outside an Electron context — so we don't import it here. - * Real e2e exercise of the capture pipeline lives in tests/e2e/. - * - * This file holds vitest-runnable shape checks for types & helpers that - * the screenshot module depends on. - */ describe('capture preferences shape', () => { it('has a string accelerator for the area mode hotkey', () => { expect(typeof DEFAULT_PREFERENCES.hotkeys.area).toBe('string'); @@ -21,3 +14,68 @@ describe('capture preferences shape', () => { } }); }); + +describe('buildArgs without a region', () => { + it('uses -i for area mode', () => { + const args = buildArgs('area', 'png', 0, '/tmp/out.png', true); + expect(args).toContain('-i'); + expect(args).not.toContain('-R'); + }); + + it('uses -i -W -o for window mode', () => { + const args = buildArgs('window', 'png', 0, '/tmp/out.png', true); + expect(args).toContain('-i'); + expect(args).toContain('-W'); + expect(args).toContain('-o'); + }); + + it('adds -T for fullscreen with delay > 0', () => { + const args = buildArgs('fullscreen', 'png', 5, '/tmp/out.png', true); + expect(args).toContain('-T'); + expect(args).toContain('5'); + }); + + it('omits -x when not silent', () => { + const args = buildArgs('area', 'png', 0, '/tmp/out.png', false); + expect(args).not.toContain('-x'); + }); +}); + +describe('buildArgs with a region — the -R-decimal regression', () => { + it('rounds fractional rect values to integers (decimals make screencapture silently fall back to fullscreen)', () => { + const args = buildArgs('area', 'png', 0, '/tmp/out.png', true, { + x: 806.2109375, + y: 61.35546875, + width: 486.203125, + height: 536.04296875, + }); + const rIndex = args.indexOf('-R'); + expect(rIndex).toBeGreaterThan(-1); + const rect = args[rIndex + 1]; + expect(rect).toBe('806,61,486,536'); + }); + + it('emits -R and skips the interactive flags when a region is set', () => { + const args = buildArgs('area', 'png', 0, '/tmp/out.png', true, { + x: 100, + y: 100, + width: 400, + height: 300, + }); + expect(args).toContain('-R'); + expect(args).toContain('100,100,400,300'); + expect(args).not.toContain('-i'); + }); + + it('rounds half-up consistently', () => { + const args = buildArgs('area', 'png', 0, '/tmp/out.png', true, { + x: 0.4, + y: 0.5, + width: 100.6, + height: 100.49, + }); + const rIndex = args.indexOf('-R'); + // Math.round(0.4)=0, Math.round(0.5)=1, Math.round(100.6)=101, Math.round(100.49)=100 + expect(args[rIndex + 1]).toBe('0,1,101,100'); + }); +});