From ade130be3bca359cb1cbb414e56d7dc9efd2399a Mon Sep 17 00:00:00 2001 From: Arya Sadeghi Date: Sun, 10 May 2026 01:55:46 +0200 Subject: [PATCH] feat(hud): stacked Quick Access overlay + position/size/auto-close settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CleanShot-style: when multiple captures land in quick succession, the post-capture HUD now stacks them vertically (newest on top, max 5 cards — oldest drops off) instead of replacing the previous card. Each card has its own copy / save / edit / discard buttons. The window auto-grows to fit the stack and shrinks back when cards are removed. Settings → Quick Access: - Position on screen: top-left / top-right / bottom-left / bottom-right - Multi-display: "Move to active screen" toggle (cursor-follow vs always-primary) - Overlay size: small / medium / large - Auto-close: enable + interval (3 / 6 / 10 / 30 s); disabling makes the HUD persist until manually dismissed IPC reshape (breaking — pre-1.0): the old single-image HUD channels (`hud:image-ready`, `hud:request-current`, `hud:copy`, `hud:save-as`, `hud:open-in-editor`, `hud:close-and-delete`) are replaced by a stack model — `hud:on-stack`, `hud:request-stack`, plus per-card variants that take a numeric id (`hud:copy-card`, `hud:save-card`, `hud:open-card-in-editor`, `hud:dismiss-card`, `hud:discard-card`). Renderer + preload + handlers all updated; no callers outside the HUD. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ipc/hudHandlers.ts | 85 ++++--- src/main/windows/hud.ts | 163 +++++++++++--- src/preload/index.ts | 27 ++- src/renderer/components/Hud.tsx | 207 ++++++++++++------ src/renderer/components/Settings.tsx | 14 +- .../components/settings/QuickAccess.tsx | 97 ++++++++ src/renderer/hud.html | 5 + src/shared/ipc.ts | 69 ++++-- src/shared/types.ts | 20 ++ 9 files changed, 529 insertions(+), 158 deletions(-) create mode 100644 src/renderer/components/settings/QuickAccess.tsx diff --git a/src/main/ipc/hudHandlers.ts b/src/main/ipc/hudHandlers.ts index 4c9260b..30affa2 100644 --- a/src/main/ipc/hudHandlers.ts +++ b/src/main/ipc/hudHandlers.ts @@ -3,62 +3,89 @@ import { copyFile, unlink } from 'node:fs/promises'; import { basename } from 'node:path'; import logger from '@main/logger'; import { showEditorWithImage } from '@main/windows/editor'; -import { dismissHud, getHudCurrentImage, showHudWithImage } from '@main/windows/hud'; +import { + dismissHud, + findHudCard, + getHudStack, + removeHudCard, + showHudWithImage, +} from '@main/windows/hud'; import { IPC } from '@shared/ipc'; export function registerHudHandlers(): void { - ipcMain.handle(IPC.hud.requestCurrent, () => getHudCurrentImage().url); + ipcMain.handle(IPC.hud.requestStack, () => getHudStack()); ipcMain.handle(IPC.hud.dismiss, () => { logger.info('hud: dismiss'); dismissHud(); }); - ipcMain.handle(IPC.hud.closeAndDelete, async () => { - const { filePath } = getHudCurrentImage(); - logger.info('hud: close + delete', { filePath }); - dismissHud(); - if (filePath) { + ipcMain.handle(IPC.hud.dismissCard, (_evt, id: number) => { + logger.info('hud: dismiss card', { id }); + removeHudCard(id); + }); + + ipcMain.handle(IPC.hud.discardCard, async (_evt, id: number) => { + const card = findHudCard(id); + logger.info('hud: discard card', { id, filePath: card?.filePath }); + const removed = removeHudCard(id); + if (removed) { try { - await unlink(filePath); - logger.info('hud: deleted', { filePath }); + await unlink(removed.filePath); + logger.info('hud: deleted', { filePath: removed.filePath }); } catch (err) { logger.warn('hud: delete failed', err); } } }); - ipcMain.handle(IPC.hud.copy, () => { - const { filePath } = getHudCurrentImage(); - logger.info('hud: copy', { filePath }); - if (!filePath) return; - const img = nativeImage.createFromPath(filePath); + ipcMain.handle(IPC.hud.copyCard, (_evt, id: number) => { + const card = findHudCard(id); + logger.info('hud: copy card', { id, filePath: card?.filePath }); + if (!card) return; + const img = nativeImage.createFromPath(card.filePath); if (!img.isEmpty()) clipboard.writeImage(img); - dismissHud(); }); - ipcMain.handle(IPC.hud.saveAs, async () => { - const { filePath } = getHudCurrentImage(); - logger.info('hud: saveAs', { filePath }); - if (!filePath) return { saved: false, path: null }; + ipcMain.handle(IPC.hud.saveCard, async (_evt, id: number) => { + const card = findHudCard(id); + logger.info('hud: save card', { id, filePath: card?.filePath }); + if (!card) return { saved: false, path: null }; const focused = BrowserWindow.getFocusedWindow(); const result = await dialog.showSaveDialog(focused ?? new BrowserWindow({ show: false }), { - defaultPath: basename(filePath), + defaultPath: basename(card.filePath), filters: [{ name: 'PNG', extensions: ['png'] }], }); if (result.canceled || !result.filePath) return { saved: false, path: null }; - await copyFile(filePath, result.filePath); - logger.info('hud: saved as', { from: filePath, to: result.filePath }); - dismissHud(); + await copyFile(card.filePath, result.filePath); + logger.info('hud: saved as', { from: card.filePath, to: result.filePath }); return { saved: true, path: result.filePath }; }); - ipcMain.handle(IPC.hud.openInEditor, () => { - const { filePath } = getHudCurrentImage(); - logger.info('hud: open in editor', { filePath }); - if (!filePath) return; - showEditorWithImage(filePath); - dismissHud(); + ipcMain.handle(IPC.hud.openCardInEditor, (_evt, id: number) => { + const card = findHudCard(id); + logger.info('hud: open card in editor', { id, filePath: card?.filePath }); + if (!card) return; + showEditorWithImage(card.filePath); + removeHudCard(id); + }); + + // Drag-and-drop OUT to other apps. Note: `ipcMain.on` (one-way `send`), + // not `handle` — `webContents.startDrag` must be called synchronously + // during a dragstart event, and an `invoke` round-trip would arrive too + // late. + ipcMain.on(IPC.hud.beginDrag, (event, id: number) => { + const card = findHudCard(id); + if (!card) return; + const fullImg = nativeImage.createFromPath(card.filePath); + if (fullImg.isEmpty()) { + logger.warn('hud: beginDrag — empty image', { id, filePath: card.filePath }); + return; + } + // 64×64 thumbnail used as the drag cursor avatar. + const icon = fullImg.resize({ height: 64 }); + logger.info('hud: drag started', { id, filePath: card.filePath }); + event.sender.startDrag({ file: card.filePath, icon }); }); // Re-export for explicit symbol use elsewhere diff --git a/src/main/windows/hud.ts b/src/main/windows/hud.ts index a85661c..92f121a 100644 --- a/src/main/windows/hud.ts +++ b/src/main/windows/hud.ts @@ -1,13 +1,16 @@ -import { BrowserWindow, screen } from 'electron'; +import { BrowserWindow, nativeImage, screen } from 'electron'; import { join } from 'node:path'; import logger from '@main/logger'; import { toSnapUrl } from '@main/security/protocol'; -import { IPC } from '@shared/ipc'; +import { getPreferences } from '@main/storage/prefs'; +import { IPC, type HudCard } from '@shared/ipc'; +import type { AppPreferences } from '@shared/types'; /** - * Quick Access HUD — small frameless window that appears bottom-right after - * every capture. Lets the user Copy / Save / Edit / discard without opening - * the full editor. + * Quick Access HUD — small frameless window that appears in the corner after + * every capture. CleanShot-style stack: when multiple captures land in quick + * succession, they pile up vertically (newest on top) instead of replacing + * one another. Lets the user copy/save/edit/discard each card individually. * * Note on transparent windows: macOS + Electron transparent + alwaysOnTop + * showInactive does NOT reliably deliver click events to WebKit, even with @@ -15,12 +18,19 @@ import { IPC } from '@shared/ipc'; * HUD that's normally interactive. */ -const HUD_WIDTH = 240; -const HUD_HEIGHT = 160; -const HUD_MARGIN = 20; +const HUD_GAP = 10; // px between stacked cards +const HUD_MARGIN = 20; // px from screen edge +const HUD_MAX_STACK = 5; + +const SIZE_BY_PREF: Record = { + small: { width: 200, cardHeight: 130 }, + medium: { width: 240, cardHeight: 160 }, + large: { width: 320, cardHeight: 215 }, +}; let hudWindow: BrowserWindow | null = null; -let currentImagePath: string | null = null; +let stack: HudCard[] = []; +let nextCardId = 1; function rendererUrl(file: string): string { if (process.env.ELECTRON_RENDERER_URL) { @@ -29,19 +39,59 @@ function rendererUrl(file: string): string { return `file://${join(__dirname, `../renderer/${file}`)}`; } -function positionBottomRight(win: BrowserWindow): void { - const cursorPoint = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPoint); +function dimensions(prefs: AppPreferences): { width: number; cardHeight: number } { + return SIZE_BY_PREF[prefs.hudSize] ?? SIZE_BY_PREF.medium; +} + +function totalHeightForStack(count: number, prefs: AppPreferences): number { + const { cardHeight } = dimensions(prefs); + if (count <= 0) return cardHeight; + return cardHeight * count + HUD_GAP * (count - 1); +} + +function placementDisplay(prefs: AppPreferences): Electron.Display { + if (prefs.hudFollowActiveScreen) { + return screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); + } + return screen.getPrimaryDisplay(); +} + +function positionForCorner(win: BrowserWindow, height: number, prefs: AppPreferences): void { + const { width } = dimensions(prefs); + const display = placementDisplay(prefs); const { workArea } = display; - const x = Math.round(workArea.x + workArea.width - HUD_WIDTH - HUD_MARGIN); - const y = Math.round(workArea.y + workArea.height - HUD_HEIGHT - HUD_MARGIN); - win.setBounds({ x, y, width: HUD_WIDTH, height: HUD_HEIGHT }); + const right = workArea.x + workArea.width - width - HUD_MARGIN; + const left = workArea.x + HUD_MARGIN; + const top = workArea.y + HUD_MARGIN; + const bottom = workArea.y + workArea.height - height - HUD_MARGIN; + + let x: number, y: number; + switch (prefs.hudPosition) { + case 'top-left': + x = left; + y = top; + break; + case 'top-right': + x = right; + y = top; + break; + case 'bottom-left': + x = left; + y = bottom; + break; + case 'bottom-right': + default: + x = right; + y = bottom; + } + win.setBounds({ x: Math.round(x), y: Math.round(y), width, height }); } -function createHudWindow(): BrowserWindow { +function createHudWindow(initialHeight: number, prefs: AppPreferences): BrowserWindow { + const { width } = dimensions(prefs); const win = new BrowserWindow({ - width: HUD_WIDTH, - height: HUD_HEIGHT, + width, + height: initialHeight, frame: false, // transparent + alwaysOnTop combine in macOS in a way that prevents click // delivery — both removed for now. Solid bg + CSS rounded corners gives a @@ -70,7 +120,10 @@ function createHudWindow(): BrowserWindow { win.setWindowButtonVisibility?.(false); win.on('closed', () => { - if (hudWindow === win) hudWindow = null; + if (hudWindow === win) { + hudWindow = null; + stack = []; + } }); if (process.env.ELECTRON_RENDERER_URL && process.env.SNAPORA_DEV_TOOLS !== '0') { @@ -83,25 +136,54 @@ function createHudWindow(): BrowserWindow { return win; } +function broadcastStack(): void { + if (!hudWindow || hudWindow.isDestroyed()) return; + hudWindow.webContents.send(IPC.hud.onStack, stack); +} + +function resizeAndPosition(): void { + if (!hudWindow || hudWindow.isDestroyed()) return; + const prefs = getPreferences(); + const height = totalHeightForStack(Math.max(stack.length, 1), prefs); + positionForCorner(hudWindow, height, prefs); +} + +/** Push a fresh capture onto the HUD stack. */ export function showHudWithImage(filePath: string): void { - currentImagePath = filePath; const url = toSnapUrl(filePath); + const img = nativeImage.createFromPath(filePath); + const size = img.isEmpty() ? null : img.getSize(); + const card: HudCard = { + id: nextCardId++, + filePath, + snapUrl: url, + width: size?.width ?? null, + height: size?.height ?? null, + capturedAt: new Date().toISOString(), + }; + + // Newest on top; cap at HUD_MAX_STACK (oldest dropped off). + stack = [card, ...stack].slice(0, HUD_MAX_STACK); if (!hudWindow || hudWindow.isDestroyed()) { - hudWindow = createHudWindow(); + const prefs = getPreferences(); + const initialHeight = totalHeightForStack(stack.length, prefs); + hudWindow = createHudWindow(initialHeight, prefs); } - positionBottomRight(hudWindow); + resizeAndPosition(); - const send = () => hudWindow?.webContents.send(IPC.hud.onImageReady, url); + const send = (): void => { + broadcastStack(); + hudWindow?.show(); + }; if (hudWindow.webContents.isLoading()) { hudWindow.webContents.once('did-finish-load', send); } else { send(); } - hudWindow.show(); - logger.info('hud: shown', { filePath }); + logger.info('hud: card pushed', { id: card.id, stackSize: stack.length, filePath }); } export function dismissHud(): void { @@ -110,9 +192,30 @@ export function dismissHud(): void { } } -export function getHudCurrentImage(): { filePath: string | null; url: string | null } { - return { - filePath: currentImagePath, - url: currentImagePath ? toSnapUrl(currentImagePath) : null, - }; +/** Read the current stack — handlers read this directly. */ +export function getHudStack(): HudCard[] { + return stack.slice(); +} + +/** Find a card by id (returns undefined if not present). */ +export function findHudCard(id: number): HudCard | undefined { + return stack.find((c) => c.id === id); +} + +/** + * Remove one card from the stack. Resizes the window to the new stack + * height; if the stack is empty after removal, hides the HUD entirely. + * Returns the removed card so callers can act on its filePath. + */ +export function removeHudCard(id: number): HudCard | undefined { + const found = stack.find((c) => c.id === id); + if (!found) return undefined; + stack = stack.filter((c) => c.id !== id); + if (stack.length === 0) { + dismissHud(); + } else { + resizeAndPosition(); + broadcastStack(); + } + return found; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 5cbb0be..3e7e8b9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,7 +8,7 @@ import type { PermissionState, SelectionRect, } from '@shared/types'; -import type { SelectionInitPayload, SnaporaApi } from '@shared/ipc'; +import type { HudCard, SelectionInitPayload, SnaporaApi } from '@shared/ipc'; const api: SnaporaApi = { capture: (options: CaptureOptions): Promise => @@ -56,18 +56,23 @@ const api: SnaporaApi = { requestCurrent: (): Promise => ipcRenderer.invoke(IPC.editor.requestCurrent), }, hud: { - onImageReady: (handler: (snapUrl: string) => void) => { - const listener = (_evt: unknown, snapUrl: string): void => handler(snapUrl); - ipcRenderer.on(IPC.hud.onImageReady, listener); - return () => ipcRenderer.removeListener(IPC.hud.onImageReady, listener); + onStack: (handler: (cards: HudCard[]) => void) => { + const listener = (_evt: unknown, cards: HudCard[]): void => handler(cards); + ipcRenderer.on(IPC.hud.onStack, listener); + return () => ipcRenderer.removeListener(IPC.hud.onStack, listener); }, - requestCurrent: (): Promise => ipcRenderer.invoke(IPC.hud.requestCurrent), + requestStack: (): Promise => ipcRenderer.invoke(IPC.hud.requestStack), dismiss: (): Promise => ipcRenderer.invoke(IPC.hud.dismiss), - closeAndDelete: (): Promise => ipcRenderer.invoke(IPC.hud.closeAndDelete), - copy: (): Promise => ipcRenderer.invoke(IPC.hud.copy), - saveAs: (): Promise<{ saved: boolean; path: string | null }> => - ipcRenderer.invoke(IPC.hud.saveAs), - openInEditor: (): Promise => ipcRenderer.invoke(IPC.hud.openInEditor), + dismissCard: (id: number): Promise => ipcRenderer.invoke(IPC.hud.dismissCard, id), + discardCard: (id: number): Promise => ipcRenderer.invoke(IPC.hud.discardCard, id), + copyCard: (id: number): Promise => ipcRenderer.invoke(IPC.hud.copyCard, id), + saveCard: (id: number): Promise<{ saved: boolean; path: string | null }> => + ipcRenderer.invoke(IPC.hud.saveCard, id), + openCardInEditor: (id: number): Promise => + ipcRenderer.invoke(IPC.hud.openCardInEditor, id), + beginDrag: (id: number): void => { + ipcRenderer.send(IPC.hud.beginDrag, id); + }, }, firstRun: { markDone: (): Promise => ipcRenderer.invoke(IPC.firstRun.markDone), diff --git a/src/renderer/components/Hud.tsx b/src/renderer/components/Hud.tsx index 875f494..0af692a 100644 --- a/src/renderer/components/Hud.tsx +++ b/src/renderer/components/Hud.tsx @@ -1,61 +1,94 @@ -import { ClipboardCopy, Download, Wand2, X } from 'lucide-react'; +import { ClipboardCopy, Download, Trash2, Wand2, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { cn } from '@renderer/lib/cn'; +import type { HudCard } from '@shared/ipc'; -const AUTO_DISMISS_MS = 6000; +type ActionKey = 'close' | 'delete' | 'edit' | 'copy' | 'save'; -type ActionKey = 'close' | 'edit' | 'copy' | 'save'; +// `WebkitAppRegion` isn't in React's strict CSSProperties type, so cast. +const DRAG_REGION = { WebkitAppRegion: 'drag' } as unknown as React.CSSProperties; +const NO_DRAG_REGION = { WebkitAppRegion: 'no-drag' } as unknown as React.CSSProperties; export function Hud() { - const [imageUrl, setImageUrl] = useState(null); - const [pendingAction, setPendingAction] = useState(null); - const [toast, setToast] = useState(null); + const [cards, setCards] = useState([]); const dismissTimer = useRef | null>(null); const isHovered = useRef(false); + // Cached auto-close pref so the timer doesn't have to await prefs each tick. + const autoCloseMs = useRef(6000); - const clearTimer = () => { + const clearTimer = (): void => { if (dismissTimer.current) { clearTimeout(dismissTimer.current); dismissTimer.current = null; } }; - const scheduleDismiss = useCallback(() => { + const scheduleDismiss = useCallback((): void => { clearTimer(); if (isHovered.current) return; + if (autoCloseMs.current == null) return; // disabled dismissTimer.current = setTimeout(() => { void window.snapora.hud.dismiss(); - }, AUTO_DISMISS_MS); + }, autoCloseMs.current); + }, []); + + // Pull HUD prefs from main on every stack push so changes in Settings + // take effect on the next capture without reloading. + const refreshAutoClose = useCallback(async (): Promise => { + const prefs = await window.snapora.preferences.get(); + autoCloseMs.current = prefs.hudAutoCloseEnabled ? prefs.hudAutoCloseSeconds * 1000 : null; }, []); useEffect(() => { - const off = window.snapora.hud.onImageReady((url) => { - setImageUrl(url); - setPendingAction(null); - setToast(null); - scheduleDismiss(); + const off = window.snapora.hud.onStack((next) => { + setCards(next); + if (next.length > 0) { + void refreshAutoClose().then(scheduleDismiss); + } }); - void window.snapora.hud.requestCurrent().then((url) => { - if (url) { - setImageUrl(url); - scheduleDismiss(); + void window.snapora.hud.requestStack().then((current) => { + if (current.length > 0) { + setCards(current); + void refreshAutoClose().then(scheduleDismiss); } }); return () => { off(); clearTimer(); }; - }, [scheduleDismiss]); + }, [refreshAutoClose, scheduleDismiss]); - const handleEnter = () => { + const handleEnter = (): void => { isHovered.current = true; clearTimer(); }; - const handleLeave = () => { + const handleLeave = (): void => { isHovered.current = false; scheduleDismiss(); }; + if (cards.length === 0) return null; + + return ( + // Wrapper is the drag region — clicking the gap above / between cards + // moves the window. Cards opt back out via NO_DRAG_REGION. +
+ {cards.map((card) => ( + + ))} +
+ ); +} + +function Card({ card }: { card: HudCard }) { + const [pendingAction, setPendingAction] = useState(null); + const [toast, setToast] = useState(null); + const run = (key: ActionKey, fn: () => Promise, successToast?: string) => async (e: React.MouseEvent) => { @@ -74,66 +107,94 @@ export function Hud() { } }; - if (!imageUrl) { - return null; - } - return (
-
- captured - - {/* Dark overlay — fades in on hover */} -
- - {toast ? ( -
- {toast} + captured { + // Suppress HTML5 drag — Electron's startDrag takes over so the + // file can be dropped into other apps as an actual file. + e.preventDefault(); + window.snapora.hud.beginDrag(card.id); + }} + /> + +
+ + {toast ? ( +
+ {toast} +
+ ) : null} + +
+ window.snapora.hud.dismissCard(card.id))} + > + + + + window.snapora.hud.discardCard(card.id))} + > + + + + window.snapora.hud.openCardInEditor(card.id))} + > + + + + {card.width && card.height ? ( +
+ {card.width} × {card.height}
) : null} - {/* Action layer — fades in on hover */} -
- window.snapora.hud.closeAndDelete())} +
+ } + onClick={run( + 'copy', + async () => { + await window.snapora.hud.copyCard(card.id); + await window.snapora.hud.dismissCard(card.id); + }, + 'Copied', + )} + disabled={pendingAction === 'copy'} > - - - - window.snapora.hud.openInEditor())} + Copy + + } + onClick={run( + 'save', + async () => { + const r = await window.snapora.hud.saveCard(card.id); + if (r.saved) await window.snapora.hud.dismissCard(card.id); + }, + 'Saved', + )} + disabled={pendingAction === 'save'} > - - - -
- } - onClick={run('copy', () => window.snapora.hud.copy(), 'Copied')} - disabled={pendingAction === 'copy'} - > - Copy - - } - onClick={run('save', () => window.snapora.hud.saveAs(), 'Saved')} - disabled={pendingAction === 'save'} - > - Save - -
+ Save +
diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index e6a9f13..0ec51be 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -1,14 +1,22 @@ -import { Image, Info, Keyboard, SlidersHorizontal, Video, Wallpaper } from 'lucide-react'; +import { Image, Info, Keyboard, Layers, SlidersHorizontal, Video, Wallpaper } from 'lucide-react'; import { useState } from 'react'; import { WindowChrome, WindowShell } from './Layout'; import { cn } from '@renderer/lib/cn'; import { AboutSettings } from './settings/About'; import { GeneralSettings } from './settings/General'; +import { QuickAccessSettings } from './settings/QuickAccess'; import { ShortcutsSettings } from './settings/Shortcuts'; import { StubSection } from './settings/Stub'; import { WallpaperSettings } from './settings/Wallpaper'; -type SectionKey = 'general' | 'shortcuts' | 'screenshot' | 'recording' | 'wallpaper' | 'about'; +type SectionKey = + | 'general' + | 'shortcuts' + | 'quickAccess' + | 'screenshot' + | 'recording' + | 'wallpaper' + | 'about'; interface NavItem { key: SectionKey; @@ -21,6 +29,7 @@ interface NavItem { const NAV: NavItem[] = [ { key: 'general', label: 'General', icon: }, { key: 'shortcuts', label: 'Shortcuts', icon: }, + { key: 'quickAccess', label: 'Quick Access', icon: }, { key: 'screenshot', label: 'Screenshot', @@ -70,6 +79,7 @@ export function Settings() {
{active === 'general' && } {active === 'shortcuts' && } + {active === 'quickAccess' && } {active === 'wallpaper' && } {active === 'about' && } {activeItem?.milestone && (active === 'screenshot' || active === 'recording') && ( diff --git a/src/renderer/components/settings/QuickAccess.tsx b/src/renderer/components/settings/QuickAccess.tsx new file mode 100644 index 0000000..4382739 --- /dev/null +++ b/src/renderer/components/settings/QuickAccess.tsx @@ -0,0 +1,97 @@ +import { Select } from '../ui/select'; +import { Switch } from '../ui/switch'; +import { Row, Section } from './SettingsLayout'; +import { usePreferences } from './usePreferences'; +import type { AppPreferences } from '@shared/types'; + +const POSITION_OPTIONS: { value: AppPreferences['hudPosition']; label: string }[] = [ + { value: 'top-left', label: 'Top-left' }, + { value: 'top-right', label: 'Top-right' }, + { value: 'bottom-left', label: 'Bottom-left' }, + { value: 'bottom-right', label: 'Bottom-right' }, +]; + +const SIZE_OPTIONS: { value: AppPreferences['hudSize']; label: string }[] = [ + { value: 'small', label: 'Small' }, + { value: 'medium', label: 'Medium' }, + { value: 'large', label: 'Large' }, +]; + +const AUTO_CLOSE_OPTIONS: { + value: '3' | '6' | '10' | '30'; + label: string; +}[] = [ + { value: '3', label: '3 seconds' }, + { value: '6', label: '6 seconds' }, + { value: '10', label: '10 seconds' }, + { value: '30', label: '30 seconds' }, +]; + +export function QuickAccessSettings() { + const { prefs, update } = usePreferences(); + + if (!prefs) return null; + + return ( +
+
+ + value={prefs.hudPosition} + onChange={(v) => void update('hudPosition', v)} + options={POSITION_OPTIONS} + /> + } + /> + void update('hudFollowActiveScreen', v)} + /> + } + /> + + value={prefs.hudSize} + onChange={(v) => void update('hudSize', v)} + options={SIZE_OPTIONS} + /> + } + /> +
+ +
+ void update('hudAutoCloseEnabled', v)} + /> + } + /> + {prefs.hudAutoCloseEnabled ? ( + + value={String(prefs.hudAutoCloseSeconds) as '3' | '6' | '10' | '30'} + onChange={(v) => void update('hudAutoCloseSeconds', Number(v) as 3 | 6 | 10 | 30)} + options={AUTO_CLOSE_OPTIONS} + /> + } + /> + ) : null} +
+
+ ); +} diff --git a/src/renderer/hud.html b/src/renderer/hud.html index c1dc15d..95b048b 100644 --- a/src/renderer/hud.html +++ b/src/renderer/hud.html @@ -8,7 +8,12 @@ html, body, #root { + margin: 0; + padding: 0; background: transparent !important; + width: 100%; + height: 100%; + overflow: hidden; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 3a23d15..26cc2ce 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -31,6 +31,21 @@ export interface HistoryItem { snapUrl: string; } +/** + * One card in the Quick Access HUD stack. The HUD shows a vertical pile of + * these — newest at the top, capped at `HUD_MAX_STACK` (in src/main/windows/hud.ts). + * `id` is a monotonic counter assigned when the card is pushed. + */ +export interface HudCard { + id: number; + filePath: string; + /** snap:// URL the renderer can load directly. */ + snapUrl: string; + width: number | null; + height: number | null; + capturedAt: string; +} + /** * Centralized IPC channel names. Both sides import from here so a typo is a compile error. */ @@ -68,13 +83,28 @@ export const IPC = { requestCurrent: 'editor:request-current', }, hud: { - onImageReady: 'hud:image-ready', - requestCurrent: 'hud:request-current', + /** main → renderer: a fresh card just landed; full stack is sent. */ + onStack: 'hud:on-stack', + /** renderer → main: pull the current stack on mount. */ + requestStack: 'hud:request-stack', + /** renderer → main: dismiss the HUD entirely (clears the stack from view). */ dismiss: 'hud:dismiss', - closeAndDelete: 'hud:close-and-delete', - copy: 'hud:copy', - saveAs: 'hud:save-as', - openInEditor: 'hud:open-in-editor', + /** renderer → main: drop one card by id (does NOT delete the file). */ + dismissCard: 'hud:dismiss-card', + /** renderer → main: drop one card AND delete its file from disk. */ + discardCard: 'hud:discard-card', + /** renderer → main: copy one card's image to the clipboard. */ + copyCard: 'hud:copy-card', + /** renderer → main: open Save As dialog for one card. */ + saveCard: 'hud:save-card', + /** renderer → main: open one card in the editor. */ + openCardInEditor: 'hud:open-card-in-editor', + /** + * renderer → main (one-way `send`): the user started dragging a card's + * image. Main calls `webContents.startDrag` so the file becomes a real + * drag-and-drop into other apps (Slack, Mail, Finder, …). + */ + beginDrag: 'hud:begin-drag', }, firstRun: { markDone: 'first-run:mark-done', @@ -127,14 +157,27 @@ export interface SnaporaApi { requestCurrent(): Promise; }; hud: { - onImageReady(handler: (snapUrl: string) => void): () => void; - requestCurrent(): Promise; + /** Subscribe to stack pushes. Handler receives the full updated stack. */ + onStack(handler: (cards: HudCard[]) => void): () => void; + /** Pull the current stack synchronously (e.g. on renderer mount). */ + requestStack(): Promise; + /** Hide the HUD without touching files. */ dismiss(): Promise; - /** Discard the capture: deletes the file from disk and dismisses the HUD. */ - closeAndDelete(): Promise; - copy(): Promise; - saveAs(): Promise<{ saved: boolean; path: string | null }>; - openInEditor(): Promise; + /** Drop one card from the stack. File on disk is preserved. */ + dismissCard(id: number): Promise; + /** Drop one card AND delete its file from disk. */ + discardCard(id: number): Promise; + /** Copy one card's image to the clipboard. */ + copyCard(id: number): Promise; + /** Open Save As dialog for one card. */ + saveCard(id: number): Promise<{ saved: boolean; path: string | null }>; + /** Open one card in the editor. */ + openCardInEditor(id: number): Promise; + /** + * Tell main to start an OS drag for this card's file. Must be called + * synchronously from a `dragstart` event so the OS picks it up. + */ + beginDrag(id: number): void; }; firstRun: { markDone(): Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index d35829e..e5a6964 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -100,6 +100,21 @@ export interface AppPreferences { windowBackgroundPaddingPx: number; /** Delay before a full-screen capture fires (gives you time to set up). 0 = no timer. */ selfTimerSeconds: 0 | 3 | 5 | 10; + + // ----- Quick Access HUD ----- + /** Where on the screen the post-capture HUD docks. */ + hudPosition: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + /** + * When true, the HUD shows on whichever display the cursor is currently on. + * When false, it always shows on the primary display. + */ + hudFollowActiveScreen: boolean; + /** Card size in the HUD. Bigger = easier to read, more screen real estate. */ + hudSize: 'small' | 'medium' | 'large'; + /** Whether the HUD auto-hides after a delay. */ + hudAutoCloseEnabled: boolean; + /** Seconds before auto-close fires when enabled. */ + hudAutoCloseSeconds: 3 | 6 | 10 | 30; /** * Route "Capture Area" through Snapora's homegrown selection overlay * (transparent fullscreen window per display) instead of `screencapture -i`. @@ -129,6 +144,11 @@ export const DEFAULT_PREFERENCES: AppPreferences = { customWallpaperColor: '#0f172a', windowBackgroundPaddingPx: 64, selfTimerSeconds: 0, + hudPosition: 'bottom-right', + hudFollowActiveScreen: true, + hudSize: 'medium', + hudAutoCloseEnabled: true, + hudAutoCloseSeconds: 6, useCustomSelectionOverlay: true, hotkeys: { area: 'CommandOrControl+Shift+2',