Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 56 additions & 29 deletions src/main/ipc/hudHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 133 additions & 30 deletions src/main/windows/hud.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
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
* acceptFirstMouse. Until that's resolved upstream we ship a solid-background
* 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<AppPreferences['hudSize'], { width: number; cardHeight: number }> = {
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) {
Expand All @@ -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
Expand Down Expand Up @@ -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') {
Expand All @@ -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 {
Expand All @@ -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;
}
27 changes: 16 additions & 11 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CaptureResult> =>
Expand Down Expand Up @@ -56,18 +56,23 @@ const api: SnaporaApi = {
requestCurrent: (): Promise<string | null> => 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<string | null> => ipcRenderer.invoke(IPC.hud.requestCurrent),
requestStack: (): Promise<HudCard[]> => ipcRenderer.invoke(IPC.hud.requestStack),
dismiss: (): Promise<void> => ipcRenderer.invoke(IPC.hud.dismiss),
closeAndDelete: (): Promise<void> => ipcRenderer.invoke(IPC.hud.closeAndDelete),
copy: (): Promise<void> => ipcRenderer.invoke(IPC.hud.copy),
saveAs: (): Promise<{ saved: boolean; path: string | null }> =>
ipcRenderer.invoke(IPC.hud.saveAs),
openInEditor: (): Promise<void> => ipcRenderer.invoke(IPC.hud.openInEditor),
dismissCard: (id: number): Promise<void> => ipcRenderer.invoke(IPC.hud.dismissCard, id),
discardCard: (id: number): Promise<void> => ipcRenderer.invoke(IPC.hud.discardCard, id),
copyCard: (id: number): Promise<void> => 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<void> =>
ipcRenderer.invoke(IPC.hud.openCardInEditor, id),
beginDrag: (id: number): void => {
ipcRenderer.send(IPC.hud.beginDrag, id);
},
},
firstRun: {
markDone: (): Promise<void> => ipcRenderer.invoke(IPC.firstRun.markDone),
Expand Down
Loading
Loading