diff --git a/README.md b/README.md index 4fafb0f..329aa7c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Lekhini +

+ Lekhini +

+ +

Lekhini

> लेखनी — Sanskrit for *"pen"*. A free, open-source on-screen > annotation overlay for macOS, Windows, and Linux. A project of diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000..9c056a9 Binary files /dev/null and b/build/icon.png differ diff --git a/electron-builder.yml b/electron-builder.yml index fc7f73c..edb8b6a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,6 +3,11 @@ productName: Lekhini copyright: Copyright © 2026 Open Source Bharat — https://opensourcebharat.org asar: true +# Single source-of-truth for the app icon. electron-builder auto- +# generates the platform-specific .icns (macOS) and .ico (Windows) +# from this PNG. Recommended: 1024×1024, transparent background. +icon: build/icon.png + directories: output: release buildResources: build diff --git a/package-lock.json b/package-lock.json index 2416f18..09e8166 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "pen-trader", - "version": "0.1.0", + "name": "@opensourcebharat/lekhini", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pen-trader", - "version": "0.1.0", + "name": "@opensourcebharat/lekhini", + "version": "1.0.0", + "license": "MIT", "dependencies": { "active-win": "^8.2.1", "electron-store": "^10.0.0", diff --git a/src/main/capture.ts b/src/main/capture.ts index 653e29a..0ef37f8 100644 --- a/src/main/capture.ts +++ b/src/main/capture.ts @@ -1,8 +1,20 @@ -import { clipboard, desktopCapturer, dialog, ipcMain, nativeImage, screen, BrowserWindow } from 'electron'; +import { + clipboard, + desktopCapturer, + dialog, + ipcMain, + nativeImage, + screen, + shell, + BrowserWindow, +} from 'electron'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { getOverlays } from './windows/overlay'; +import { notifyStatus, onFocusRecheck, screenStatus } from './permissions'; +import { persisted } from './persistence'; +import { patch as patchHub } from './hub'; interface Rect { x: number; @@ -37,7 +49,50 @@ export function getFocusedDisplayId(): number { return screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).id; } +// ─── Permission gating ────────────────────────────────────────────── +// +// macOS controls whether `desktopCapturer.getSources()` returns +// anything. We never preflight-bail: on 'granted' we proceed, on +// 'not-determined' we still call so the OS shows its native +// first-run prompt, and only on 'denied' do we surface our own +// modal. When the user grants the permission and refocuses Lekhini, +// onFocusRecheck retries the pending capture automatically. + +type PendingAction = 'capture' | 'clipboard'; +let pendingAction: PendingAction | null = null; + +function broadcast(channel: string, payload?: unknown): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(channel, payload); + } +} + +function needsScreenModal(): boolean { + if (process.platform !== 'darwin') return false; + const status = screenStatus(); + return status === 'denied' || status === 'restricted'; +} + +function gateScreenForCapture(action: PendingAction): boolean { + if (!needsScreenModal()) return true; + broadcast('permissions:needed', { reason: 'screen' }); + pendingAction = action; + onFocusRecheck((result) => { + // Pass the fresh status explicitly so renderers don't see the + // possibly-stale getMediaAccessStatus cache. + notifyStatus(result.screen, result.probeError); + if (result.screen !== 'granted') return; + const a = pendingAction; + pendingAction = null; + if (a === 'capture') void captureFocusedDisplay(); + else if (a === 'clipboard') void copyFocusedSnipToClipboard(); + }); + return false; +} + export async function copyFocusedSnipToClipboard(): Promise { + if (!gateScreenForCapture('clipboard')) return; + const displayId = getFocusedDisplayId(); const rect = snipSelections.get(displayId); if (!rect) return; @@ -52,24 +107,36 @@ export async function copyFocusedSnipToClipboard(): Promise { setSnipSelection(displayId, null); await waitMs(60); - const pngBase64 = await captureCroppedComposite(overlay, display, rect); - if (!pngBase64) return; + const png = await captureCroppedComposite(overlay, display, rect); + if (!png) { + if (handleCaptureFailure()) return; + broadcast('capture:error', { + message: "Couldn't read the screen — try again.", + recoverable: true, + }); + return; + } - const buf = Buffer.from(pngBase64, 'base64'); - const img = nativeImage.createFromBuffer(buf); + const img = nativeImage.createFromBuffer(png); clipboard.writeImage(img); } export async function captureFocusedDisplay(): Promise { + if (!gateScreenForCapture('capture')) return; + const cursorPoint = screen.getCursorScreenPoint(); const display = screen.getDisplayNearestPoint(cursorPoint); const overlay = getOverlays().get(display.id); const selection = snipSelections.get(display.id) ?? null; if (!overlay || overlay.isDestroyed()) { - // No overlay: fall back to a raw full-display capture. - const dataUrl = await fullDisplayDataUrl(display); - if (dataUrl) await persistDataUrl(dataUrl); + // No overlay: fall back to a raw full-display capture (uncomposited). + const raw = await fullDisplayPng(display); + if (!raw) { + handleCaptureFailure(); + return; + } + await persistPng(raw); return; } @@ -77,24 +144,35 @@ export async function captureFocusedDisplay(): Promise { // Clear the dashed selection visually before the screen grab. setSnipSelection(display.id, null); await waitMs(60); - const pngBase64 = await captureCroppedComposite(overlay, display, selection); - if (pngBase64) await persistDataUrl(`data:image/png;base64,${pngBase64}`); + const png = await captureCroppedComposite(overlay, display, selection); + if (!png) { + handleCaptureFailure(); + return; + } + await persistPng(png); return; } // No selection: full-display composite (existing behavior). - const dataUrl = await fullDisplayDataUrl(display); - if (!dataUrl) return; + const screenPng = await fullDisplayPng(display); + if (!screenPng) { + handleCaptureFailure(); + return; + } await new Promise((resolve) => { const channel = 'capture:screenshot:result'; - const handler = async (_evt: Electron.IpcMainInvokeEvent, pngBase64: string) => { + const handler = async (_evt: Electron.IpcMainInvokeEvent, png: Uint8Array) => { ipcMain.removeHandler(channel); - await persistDataUrl(`data:image/png;base64,${pngBase64}`); + await persistPng(Buffer.from(png)); resolve(); }; ipcMain.handle(channel, handler); - overlay.webContents.send('overlay:screenshot', { dataUrl }); + // Send the PNG as a Uint8Array — structured-clones across IPC as + // raw bytes (no base64 round-trip), about 33% less data than the + // old dataURL string and noticeably faster decode in the renderer + // via createImageBitmap. + overlay.webContents.send('overlay:screenshot', { png: screenPng }); setTimeout(() => { ipcMain.removeHandler(channel); resolve(); @@ -102,7 +180,27 @@ export async function captureFocusedDisplay(): Promise { }); } -async function fullDisplayDataUrl(display: Electron.Display): Promise { +// Called when desktopCapturer returns nothing. On macOS this almost +// always means permission was denied at the system prompt (which we +// can't intercept). Re-check status and surface the modal so the user +// gets feedback instead of silent failure. +function handleCaptureFailure(): boolean { + if (needsScreenModal()) { + broadcast('permissions:needed', { reason: 'screen' }); + pendingAction = 'capture'; + onFocusRecheck((result) => { + notifyStatus(result.screen, result.probeError); + if (result.screen === 'granted' && pendingAction === 'capture') { + pendingAction = null; + void captureFocusedDisplay(); + } + }); + return true; + } + return false; +} + +async function fullDisplayPng(display: Electron.Display): Promise { const sources = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: { @@ -113,26 +211,28 @@ async function fullDisplayDataUrl(display: Electron.Display): Promise Number(s.display_id) === display.id) ?? sources[0]; if (!matching) return null; - return matching.thumbnail.toDataURL(); + // toPNG() goes straight to a Buffer — no base64 dataURL middleman. + // Buffer is structured-clonable over IPC as raw bytes. + return matching.thumbnail.toPNG(); } async function captureCroppedComposite( overlay: BrowserWindow, display: Electron.Display, rect: Rect, -): Promise { - const screenDataUrl = await fullDisplayDataUrl(display); - if (!screenDataUrl) return null; +): Promise { + const screenPng = await fullDisplayPng(display); + if (!screenPng) return null; - return new Promise((resolve) => { + return new Promise((resolve) => { const channel = 'capture:snip:result'; - const handler = (_evt: Electron.IpcMainInvokeEvent, pngBase64: string) => { + const handler = (_evt: Electron.IpcMainInvokeEvent, png: Uint8Array) => { ipcMain.removeHandler(channel); - resolve(pngBase64 || null); + resolve(png && png.byteLength > 0 ? Buffer.from(png) : null); }; ipcMain.handle(channel, handler); overlay.webContents.send('overlay:snip', { - dataUrl: screenDataUrl, + png: screenPng, rect, scaleFactor: display.scaleFactor, }); @@ -147,22 +247,67 @@ function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function persistDataUrl(dataUrl: string): Promise { - const base64 = dataUrl.replace(/^data:image\/png;base64,/, ''); - const buf = Buffer.from(base64, 'base64'); +// Default filename: `lekhini-YYYY-MM-DD-HHMMSS.png`. Stable enough to +// sort chronologically, short enough to read at a glance. +function defaultFilename(): string { + const d = new Date(); + const pad = (n: number): string => String(n).padStart(2, '0'); + return ( + `lekhini-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.png` + ); +} - const day = new Date().toISOString().slice(0, 10); - const defaultDir = path.join(os.homedir(), 'Pictures', 'Lekhini', day); - fs.mkdirSync(defaultDir, { recursive: true }); - const defaultPath = path.join(defaultDir, `lekhini-${Date.now()}.png`); +function defaultSaveDir(): string { + return path.join(os.homedir(), 'Pictures', 'Lekhini'); +} - const result = await dialog.showSaveDialog({ - defaultPath, - filters: [{ name: 'PNG', extensions: ['png'] }], - }); +async function persistPng(buf: Buffer): Promise { + const state = persisted(); + const shouldPrompt = state.alwaysAskSavePath || !state.saveDir; - if (result.canceled || !result.filePath) return; - fs.writeFileSync(result.filePath, buf); + let target: string; + if (shouldPrompt) { + const seedDir = state.saveDir ?? defaultSaveDir(); + try { + fs.mkdirSync(seedDir, { recursive: true }); + } catch { + // Non-fatal — showSaveDialog will still work even if mkdir failed. + } + const result = await dialog.showSaveDialog({ + title: 'Save annotated screenshot', + defaultPath: path.join(seedDir, defaultFilename()), + filters: [{ name: 'PNG', extensions: ['png'] }], + }); + if (result.canceled || !result.filePath) return; + target = result.filePath; + // Remember the chosen folder so the next save can skip the dialog. + // Going through the hub keeps every renderer's Settings panel in sync. + patchHub({ saveDir: path.dirname(target) }); + } else { + // saveDir is non-null here per the check above. + const dir = state.saveDir!; + try { + fs.mkdirSync(dir, { recursive: true }); + } catch (err) { + broadcast('capture:error', { + message: `Couldn't create save folder ${dir}: ${(err as Error).message}`, + recoverable: true, + }); + return; + } + target = path.join(dir, defaultFilename()); + } + + try { + fs.writeFileSync(target, buf); + broadcast('capture:saved', { path: target }); + } catch (err) { + broadcast('capture:error', { + message: `Couldn't save to ${target}: ${(err as Error).message}`, + recoverable: true, + }); + } } export function registerCaptureIpc() { @@ -172,5 +317,32 @@ export function registerCaptureIpc() { ipcMain.handle('snip:clear', (_evt, payload: { displayId: number }) => { setSnipSelection(payload.displayId, null); }); + // Renderer-triggered folder picker, used by the "Change…" button in + // Settings → File save. Returns the chosen path so the renderer can + // patch the hub with it (which is what persists + broadcasts to + // every window). We don't save here ourselves — the renderer owns + // the round-trip to keep the hub the single source of truth. + ipcMain.handle('settings:save-dir:pick', async () => { + const state = persisted(); + const result = await dialog.showOpenDialog({ + title: 'Choose save folder for screenshots', + defaultPath: state.saveDir ?? defaultSaveDir(), + properties: ['openDirectory', 'createDirectory'], + }); + if (result.canceled || !result.filePaths.length) return null; + return result.filePaths[0]; + }); + // Reveal-in-Finder / file-manager link for the saved-toast. + ipcMain.handle('shell:open-path', async (_evt, p: string) => { + if (!p) return; + try { + // showItemInFolder reveals the file with it selected — better + // than openPath which just opens the parent folder. + shell.showItemInFolder(p); + } catch { + // Fall back to opening the containing folder. + void shell.openPath(path.dirname(p)); + } + }); void BrowserWindow; } diff --git a/src/main/hub.ts b/src/main/hub.ts index 44928d3..db099dc 100644 --- a/src/main/hub.ts +++ b/src/main/hub.ts @@ -27,6 +27,13 @@ export interface HubState { settingsOpen: boolean; thicknessFlyoutOpen: boolean; perToolWidth: PerToolWidth; + saveDir: string | null; + alwaysAskSavePath: boolean; + // Whether the renderer is currently showing the status side panel + // (permission / save error). Transient — never persisted. Tracked + // in hub so main can grow the toolbar window to fit, the same way + // it does for settingsOpen. + statusPanelOpen: boolean; } const state: HubState = { @@ -42,6 +49,9 @@ const state: HubState = { settingsOpen: false, thicknessFlyoutOpen: false, perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 }, + saveDir: null, + alwaysAskSavePath: false, + statusPanelOpen: false, }; const subscribers = new Set(); @@ -100,6 +110,10 @@ export function hydrateFromPersistence(): void { highlighter: typeof storedW.highlighter === 'number' ? storedW.highlighter : PERSISTED_DEFAULTS.perToolWidth.highlighter, }; state.activeTool = VALID_TOOLS.has(p.activeTool) ? p.activeTool : 'pencil'; + // Save destination: null until the user picks one. Schema-tolerant — + // older installs without this key fall through to the default. + state.saveDir = typeof p.saveDir === 'string' ? p.saveDir : null; + state.alwaysAskSavePath = typeof p.alwaysAskSavePath === 'boolean' ? p.alwaysAskSavePath : false; // If the active tool is pencil, the canonical color is graphite — // don't restore a stray non-graphite value from a previous session. const colorForTool = @@ -247,6 +261,33 @@ export function patch(update: HubStateUpdate) { changed.add('settingsOpen'); } } + if (update.saveDir !== undefined && update.saveDir !== state.saveDir) { + state.saveDir = update.saveDir; + changed.add('saveDir'); + save('saveDir', state.saveDir); + } + if ( + update.alwaysAskSavePath !== undefined && + update.alwaysAskSavePath !== state.alwaysAskSavePath + ) { + state.alwaysAskSavePath = update.alwaysAskSavePath; + changed.add('alwaysAskSavePath'); + save('alwaysAskSavePath', state.alwaysAskSavePath); + } + if ( + update.statusPanelOpen !== undefined && + update.statusPanelOpen !== state.statusPanelOpen + ) { + state.statusPanelOpen = update.statusPanelOpen; + changed.add('statusPanelOpen'); + // Status panel and settings are mutually exclusive panels in the + // same dock slot — opening one closes the other so the renderer + // and main agree on what's showing. + if (state.statusPanelOpen && state.settingsOpen) { + state.settingsOpen = false; + changed.add('settingsOpen'); + } + } broadcast(changed); } diff --git a/src/main/main.ts b/src/main/main.ts index 9d27737..58515a7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -59,10 +59,18 @@ app.whenReady().then(async () => { registerEscapeWhileDrawing(state.drawMode); registerDrawingHotkeys(state.drawMode); } + // The status panel (permission / save error) occupies the same + // dock slot as Settings in the toolbar, so we treat either being + // open as "the side panel is showing" for window-resize purposes. + const sidePanelOpen = state.settingsOpen || state.statusPanelOpen; if (changed.has('orientation')) { - resizeToolbar(state.orientation, state.minimized, state.settingsOpen, 'default'); - } else if (changed.has('minimized') || changed.has('settingsOpen')) { - resizeToolbar(state.orientation, state.minimized, state.settingsOpen, 'keep'); + resizeToolbar(state.orientation, state.minimized, sidePanelOpen, 'default'); + } else if ( + changed.has('minimized') || + changed.has('settingsOpen') || + changed.has('statusPanelOpen') + ) { + resizeToolbar(state.orientation, state.minimized, sidePanelOpen, 'keep'); } }); diff --git a/src/main/permissions.ts b/src/main/permissions.ts index 4600002..c8ea32d 100644 --- a/src/main/permissions.ts +++ b/src/main/permissions.ts @@ -1,12 +1,23 @@ -import { ipcMain, shell, systemPreferences } from 'electron'; +import { + app, + BrowserWindow, + desktopCapturer, + ipcMain, + shell, + systemPreferences, +} from 'electron'; +import type { ScreenPermissionStatus } from '../shared/types'; export interface PermissionStatus { - screen: 'granted' | 'denied' | 'restricted' | 'not-determined' | 'unknown'; + screen: ScreenPermissionStatus; accessibility: boolean; } export function check(): PermissionStatus { if (process.platform !== 'darwin') { + // On Windows + X11 there is no permission gate. On Wayland the + // portal mediates at call time so we still report 'granted' here + // and let the portal prompt fire when desktopCapturer is invoked. return { screen: 'granted', accessibility: true }; } return { @@ -15,6 +26,56 @@ export function check(): PermissionStatus { }; } +export function screenStatus(): ScreenPermissionStatus { + return check().screen; +} + +export interface DeepRecheckResult { + screen: ScreenPermissionStatus; + // True when desktopCapturer.getSources() threw outright (not just + // returned an empty array). On macOS this signals that Chromium's + // in-process capture pipeline initialised while permission was + // denied and is now permanently stuck — only restarting the + // process will recover. The renderer uses this to make "Relaunch" + // the recommended next action instead of "Recheck". + probeError: boolean; +} + +// Active probe of the actual capture API. macOS's TCC layer returns a +// cached value from getMediaAccessStatus that does NOT refresh just +// because the user toggled Screen Recording on in System Settings — +// the cache only updates after an API call that hits the system +// capture stack. Without this probe our "Recheck" button would keep +// reporting 'denied' for the rest of the process lifetime even after +// permission is actually granted, which is what bit our user. +// +// The probe requests a 1×1 thumbnail so it returns fast and uses +// almost no memory. If we get any sources back, permission is real +// and the user can proceed. +export async function deepRecheck(): Promise { + if (process.platform !== 'darwin') return { screen: 'granted', probeError: false }; + const cached = screenStatus(); + if (cached === 'granted') return { screen: 'granted', probeError: false }; + try { + const sources = await desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { width: 1, height: 1 }, + }); + if (sources.length > 0) return { screen: 'granted', probeError: false }; + return { screen: screenStatus(), probeError: false }; + } catch (err) { + console.warn('[pen] deepRecheck capture probe failed', err); + // The throw itself is the diagnostic signal — the process can't + // recover until restart. Re-read cache one more time in case it + // flipped during the probe call. + const after = screenStatus(); + return { + screen: after === 'granted' ? 'granted' : cached, + probeError: true, + }; + } +} + export function open(which: 'screen' | 'accessibility') { if (process.platform !== 'darwin') return; const urls = { @@ -25,7 +86,66 @@ export function open(which: 'screen' | 'accessibility') { shell.openExternal(urls[which]); } +// Broadcast a permission-status update to every renderer that's +// currently alive. Used by onFocusRecheck and by capture.ts when it +// discovers a denial through the capturer's empty-source return. +// +// Pass `override` when you already know the fresh status — e.g. after +// deepRecheck() — so renderers receive the truth instead of the +// possibly-stale cached value from getMediaAccessStatus. `probeError` +// piggy-backs along so the renderer can promote the Relaunch button +// when getSources() outright threw (process-permanently-stuck case). +export function notifyStatus( + override?: ScreenPermissionStatus, + probeError = false, +): void { + const payload = { screen: override ?? screenStatus(), probeError }; + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send('permissions:status', payload); + } +} + +// macOS-only: register a one-shot listener that fires the next time +// any of our windows regains focus. The user clicked "Open System +// Settings" in the side panel, granted the permission, then +// alt-tabbed back to Lekhini — that focus event is our signal to +// recheck. +// +// Only one listener at a time: each call to onFocusRecheck swaps the +// pending callback in-place rather than stacking another `app.once`. +// The previous behaviour (stacking) leaked listeners every time a +// denied screenshot was attempted, eventually hitting Node's +// MaxListeners warning at 11. +let pendingRecheck: ((result: DeepRecheckResult) => void) | null = null; +let activeFocusHandler: (() => void) | null = null; +export function onFocusRecheck(cb: (result: DeepRecheckResult) => void): void { + pendingRecheck = cb; + if (activeFocusHandler) return; // already armed; just updated cb + activeFocusHandler = () => { + const fn = pendingRecheck; + pendingRecheck = null; + activeFocusHandler = null; + if (fn) { + // Defer one tick — macOS sometimes updates TCC state slightly + // after the focus event fires — then deep-probe so a freshly + // granted permission is actually seen. + setTimeout(() => { + void deepRecheck().then(fn); + }, 120); + } + }; + app.once('browser-window-focus', activeFocusHandler); +} + export function registerPermissionsIpc() { ipcMain.handle('permissions:check', () => check()); ipcMain.handle('permissions:open', (_evt, which: 'screen' | 'accessibility') => open(which)); + ipcMain.handle('permissions:deep-recheck', () => deepRecheck()); + // Relaunch escape hatch — surfaced in the permission panel for the + // rare macOS cases where even the deep probe can't see a freshly + // granted permission until the process restarts. + ipcMain.handle('app:relaunch', () => { + app.relaunch(); + app.exit(0); + }); } diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d9f69b3..677e740 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -8,6 +8,15 @@ export interface PersistedState { perToolWidth: { pencil: number; pen: number; eraser: number; highlighter: number }; color: string; activeTool: ToolId; + // Save destination for screenshot / snip PNGs. `null` until the + // first save — the first save shows the OS dialog so the user + // explicitly picks a folder; that folder is then remembered and + // subsequent saves go straight to it with a timestamped filename. + saveDir: string | null; + // If true, every save shows the OS dialog regardless of saveDir. + // Off by default — the "remember + auto-save" UX is the recommended + // path. Lives in Settings → File save. + alwaysAskSavePath: boolean; } export const PERSISTED_DEFAULTS: PersistedState = { @@ -16,9 +25,11 @@ export const PERSISTED_DEFAULTS: PersistedState = { orientation: 'v', theme: 'dark', profile: 'general', - perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 }, + perToolWidth: { pencil: 2, pen: 4, eraser: 20, highlighter: 18 }, color: GRAPHITE_COLOR, activeTool: 'pencil', + saveDir: null, + alwaysAskSavePath: false, }; interface MinimalStore { diff --git a/src/main/preload.ts b/src/main/preload.ts index 95c4c2e..a1f1ef4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -16,11 +16,11 @@ const api = { onUndo: (cb: () => void) => bind('overlay:undo', cb), onRedo: (cb: () => void) => bind('overlay:redo', cb), onClear: (cb: () => void) => bind('overlay:clear', cb), - onScreenshot: (cb: (payload: { dataUrl: string }) => void) => + onScreenshot: (cb: (payload: { png: Uint8Array }) => void) => bind('overlay:screenshot', cb as (v: unknown) => void), onSnip: ( cb: (payload: { - dataUrl: string; + png: Uint8Array; rect: { x: number; y: number; w: number; h: number }; scaleFactor: number; }) => void, @@ -30,10 +30,10 @@ const api = { ) => bind('overlay:snip-selection', cb as (v: unknown) => void), requestFocus: () => ipcRenderer.invoke('overlay:request-focus' satisfies IpcChannel), releaseFocus: () => ipcRenderer.invoke('overlay:release-focus' satisfies IpcChannel), - sendScreenshotResult: (pngBase64: string) => - ipcRenderer.invoke('capture:screenshot:result' satisfies IpcChannel, pngBase64), - sendSnipResult: (pngBase64: string) => - ipcRenderer.invoke('capture:snip:result' satisfies IpcChannel, pngBase64), + sendScreenshotResult: (png: Uint8Array) => + ipcRenderer.invoke('capture:screenshot:result' satisfies IpcChannel, png), + sendSnipResult: (png: Uint8Array) => + ipcRenderer.invoke('capture:snip:result' satisfies IpcChannel, png), }, snip: { set: (payload: { @@ -61,8 +61,35 @@ const api = { }, permissions: { check: () => ipcRenderer.invoke('permissions:check' satisfies IpcChannel), + deepCheck: () => ipcRenderer.invoke('permissions:deep-recheck' satisfies IpcChannel), open: (which: 'screen' | 'accessibility') => ipcRenderer.invoke('permissions:open' satisfies IpcChannel, which), + onNeeded: (cb: (payload: { reason: 'screen' }) => void) => + bind('permissions:needed', cb as (v: unknown) => void), + onStatus: ( + cb: (payload: { + screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + probeError?: boolean; + }) => void, + ) => bind('permissions:status', cb as (v: unknown) => void), + }, + capture: { + onSaved: (cb: (payload: { path: string }) => void) => + bind('capture:saved', cb as (v: unknown) => void), + onError: (cb: (payload: { message: string; recoverable: boolean }) => void) => + bind('capture:error', cb as (v: unknown) => void), + }, + settings: { + // saveDir + alwaysAskSavePath are part of HubState — write them + // via `pen.hub.update({ saveDir, alwaysAskSavePath })`. This + // method only opens the OS folder-picker dialog and returns the + // chosen path so the renderer can patch the hub with it. + pickSaveDir: () => + ipcRenderer.invoke('settings:save-dir:pick' satisfies IpcChannel) as Promise, + }, + shell: { + openPath: (p: string) => + ipcRenderer.invoke('shell:open-path' satisfies IpcChannel, p), }, app: { info: () => @@ -70,6 +97,7 @@ const api = { name: string; version: string; }>, + relaunch: () => ipcRenderer.invoke('app:relaunch' satisfies IpcChannel), }, env: { displayId: () => ipcRenderer.sendSync('overlay:display-id'), diff --git a/src/main/windows/toolbar.ts b/src/main/windows/toolbar.ts index ee1013e..c38a1d4 100644 --- a/src/main/windows/toolbar.ts +++ b/src/main/windows/toolbar.ts @@ -13,7 +13,9 @@ let toolbar: BrowserWindow | null = null; // of drifting inward each open/close cycle. let anchorPos: { x: number; y: number } | null = null; -const MIN_SIZE = { w: 56, h: 56 }; +// Collapsed pill: square enough to be a chunky tap target and big +// enough that a 36px logo with a 5px drag border breathes. +const MIN_SIZE = { w: 64, h: 64 }; function defaultPosition(orientation: Orientation, minimized: boolean, settingsOpen: boolean) { const primary = screen.getPrimaryDisplay(); @@ -65,6 +67,13 @@ export function createToolbar(orientation: Orientation = 'h'): BrowserWindow { toolbar.setAlwaysOnTop(true, 'screen-saver', 2); toolbar.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + // Hide the toolbar from screen capture so the screenshot the user + // takes via Lekhini contains the underlying app + their annotations + // but NOT our toolbar chrome. macOS uses NSWindowSharingNone; + // Windows uses SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE); + // Linux is no-op. Also keeps the toolbar out of any other capture + // tool the user runs (Loom, QuickTime, Zoom share, etc.). + toolbar.setContentProtection(true); if (VITE_DEV_SERVER_URL) { toolbar.loadURL(`${VITE_DEV_SERVER_URL}src/renderer/toolbar/index.html`); @@ -170,6 +179,10 @@ export function registerToolbarIpc() { ipcMain.handle('app:info', () => ({ name: app.getName(), version: app.getVersion(), + // isPackaged is the canonical Electron check for production vs + // dev-server run. Surfaced so the renderer can flag dev-mode TCC + // quirks (which are normal in dev but absent in packaged builds). + packaged: app.isPackaged, })); // Renderer reports its desired content height (vertical) or width // (horizontal). Main resizes the window to fit so empty space below diff --git a/src/renderer/overlay/App.tsx b/src/renderer/overlay/App.tsx index e25782f..9804517 100644 --- a/src/renderer/overlay/App.tsx +++ b/src/renderer/overlay/App.tsx @@ -3,7 +3,7 @@ import { CommittedLayer } from './canvas/CommittedLayer'; import { LiveLayer } from './canvas/LiveLayer'; import { attachPointerPipeline } from './canvas/pointerPipeline'; import { cursorFor } from './cursors'; -import { store } from './store'; +import { store, type SnipRect } from './store'; import { buildRegistry } from './tools/registry'; import type { Item, Theme, ToolSettings, Whiteboard } from '../../shared/types'; import type { Tool, ToolContext } from './tools/types'; @@ -15,6 +15,10 @@ export function OverlayApp() { const [drawMode, setDrawMode] = createSignal(store.getState().drawMode); const [activeTool, setActiveToolSignal] = createSignal(store.getState().activeTool); const [whiteboard, setWhiteboard] = createSignal('off'); + // Reactive mirror of the store's snipRect so the SnipActions menu + // can re-render on Solid's signal cycle. Synced inside the store + // subscriber below. + const [snipRectSig, setSnipRectSig] = createSignal(null); let currentTheme: Theme = 'dark'; const applyCursor = () => { @@ -134,6 +138,7 @@ export function OverlayApp() { ) { committed.render(state.items, state.selectedId, state.snipRect); } + if (state.snipRect !== prev.snipRect) setSnipRectSig(state.snipRect); }); const onResize = () => { @@ -150,13 +155,13 @@ export function OverlayApp() { const unUndo = window.pen.overlay.onUndo(() => store.getState().undo()); const unRedo = window.pen.overlay.onRedo(() => store.getState().redo()); const unClear = window.pen.overlay.onClear(() => store.getState().clear()); - const unShot = window.pen.overlay.onScreenshot(async ({ dataUrl }) => { - const png = await composite(dataUrl, committed.getCanvas()); - await window.pen.overlay.sendScreenshotResult(png); + const unShot = window.pen.overlay.onScreenshot(async ({ png }) => { + const out = await composite(png, committed.getCanvas()); + await window.pen.overlay.sendScreenshotResult(out); }); - const unSnip = window.pen.overlay.onSnip(async ({ dataUrl, rect, scaleFactor }) => { - const png = await compositeAndCrop(dataUrl, committed.getCanvas(), rect, scaleFactor); - await window.pen.overlay.sendSnipResult(png); + const unSnip = window.pen.overlay.onSnip(async ({ png, rect, scaleFactor }) => { + const out = await compositeAndCrop(png, committed.getCanvas(), rect, scaleFactor); + await window.pen.overlay.sendSnipResult(out); }); const unSnipSel = window.pen.overlay.onSnipSelection((rect) => { store.getState().setSnipRect(rect); @@ -250,6 +255,116 @@ export function OverlayApp() { /> )} + {/* Menu is only operable while the snip tool is the active + drawing tool, because the overlay window is click-through + otherwise (setIgnoreMouseEvents). Hiding it then prevents a + visible-but-dead menu floating on screen. The underlying + selection stays in main's snipSelections map either way. + (Order matters: snipRectSig() goes last so the && chain + resolves to the SnipRect itself for Show's accessor.) */} + + {(rect) => } + + + ); +} + +// Floating Copy / Save / Cancel menu for the active snip selection. +// Anchored at the bottom-right corner of the rect with a small offset. +// Falls back to inside-rect-bottom-right if the rect is too close to +// the screen edge to fit the menu below it. +function SnipActions(props: { rect: SnipRect }) { + const MENU_W = 168; + const MENU_H = 32; + const GAP = 8; + // Tracks an in-flight Copy so the button can show 'Copying…' and + // block double-clicks. Save is fire-and-forget (capture goes + // through the toolbar's save flow), so it doesn't get a busy state + // — the menu just dismisses immediately. + const [busy, setBusy] = createSignal<'copy' | null>(null); + + const clearSnip = (): void => { + const displayId = window.pen.env.displayId(); + void window.pen.snip.clear({ displayId }); + }; + // After the user picks Copy or Save the snip is done — drop the + // overlay out of drawMode so it becomes click-through immediately, + // letting the user paste into another app or click around without + // the snip tool intercepting the next click. The snip tool stays + // selected, so re-enabling drawMode (⌘⇧D or status dot) jumps + // straight back into another selection. + const exitToIdle = (): void => { + void window.pen.hub.update({ drawMode: false }); + }; + const onCopy = async (): Promise => { + if (busy()) return; + setBusy('copy'); + try { + await window.pen.snip.copy(); + } finally { + setBusy(null); + clearSnip(); + exitToIdle(); + } + }; + const onSave = (): void => { + // The main process's captureFocusedDisplay picks up the existing + // selection and writes a cropped PNG (going through the save + // dialog or the remembered folder). capture.ts also clears the + // visual selection itself just before grabbing the pixels so it + // isn't baked into the PNG. + void window.pen.relay.screenshot(); + exitToIdle(); + }; + const onCancel = (): void => clearSnip(); + + const positioned = (): { left: string; top: string } => { + const r = props.rect; + const winW = window.innerWidth; + const winH = window.innerHeight; + // Default: below the rect, right-aligned to its right edge. + let left = r.x + r.w - MENU_W; + let top = r.y + r.h + GAP; + // If it would overflow the bottom of the screen, place ABOVE the rect. + if (top + MENU_H > winH - 4) top = r.y - MENU_H - GAP; + // If still off-screen (very tall rect near top), tuck inside the rect. + if (top < 4) top = Math.min(r.y + r.h - MENU_H - GAP, winH - MENU_H - 4); + // Horizontal clamping: never let the menu fall off either edge. + left = Math.max(4, Math.min(left, winW - MENU_W - 4)); + return { left: `${left}px`, top: `${top}px` }; + }; + + return ( +
e.stopPropagation()} + > + + +
); } @@ -270,58 +385,86 @@ function TextPrompt(props: { x: number; y: number; onCommit: (s: string) => void ); } -async function composite(screenDataUrl: string, annotationCanvas: HTMLCanvasElement): Promise { - const img = await loadImage(screenDataUrl); +// Composite the full-screen capture with the overlay's annotations, +// returning a PNG buffer. Uses createImageBitmap + canvas.toBlob — +// both run off-thread where the browser supports it, and avoid the +// expensive HTMLImageElement.src = dataURL round-trip that the +// previous string-based path used. +async function composite( + screenPng: Uint8Array, + annotationCanvas: HTMLCanvasElement, +): Promise { + const bitmap = await pngToBitmap(screenPng); + if (!bitmap) return new Uint8Array(); const off = document.createElement('canvas'); - off.width = img.naturalWidth; - off.height = img.naturalHeight; + off.width = bitmap.width; + off.height = bitmap.height; const ctx = off.getContext('2d'); - if (!ctx) return ''; - ctx.drawImage(img, 0, 0); + if (!ctx) return new Uint8Array(); + ctx.drawImage(bitmap, 0, 0); ctx.drawImage(annotationCanvas, 0, 0, off.width, off.height); - const dataUrl = off.toDataURL('image/png'); - return dataUrl.replace(/^data:image\/png;base64,/, ''); + bitmap.close(); + return canvasToPng(off); } async function compositeAndCrop( - screenDataUrl: string, + screenPng: Uint8Array, annotationCanvas: HTMLCanvasElement, rect: { x: number; y: number; w: number; h: number }, scaleFactor: number, -): Promise { - const img = await loadImage(screenDataUrl); +): Promise { + const bitmap = await pngToBitmap(screenPng); + if (!bitmap) return new Uint8Array(); - // First composite full display: screen + annotations scaled to screen pixels. + // Composite the full display first so the annotation canvas (which + // is sized to the overlay window, not to the screen capture) draws + // at the same scale as the underlying pixels. const full = document.createElement('canvas'); - full.width = img.naturalWidth; - full.height = img.naturalHeight; + full.width = bitmap.width; + full.height = bitmap.height; const fctx = full.getContext('2d'); - if (!fctx) return ''; - fctx.drawImage(img, 0, 0); + if (!fctx) { + bitmap.close(); + return new Uint8Array(); + } + fctx.drawImage(bitmap, 0, 0); fctx.drawImage(annotationCanvas, 0, 0, full.width, full.height); + bitmap.close(); // Then crop to the user's CSS-px rect, scaled to display pixels. const sx = Math.max(0, Math.round(rect.x * scaleFactor)); const sy = Math.max(0, Math.round(rect.y * scaleFactor)); const sw = Math.min(Math.round(rect.w * scaleFactor), full.width - sx); const sh = Math.min(Math.round(rect.h * scaleFactor), full.height - sy); - if (sw <= 0 || sh <= 0) return ''; + if (sw <= 0 || sh <= 0) return new Uint8Array(); const off = document.createElement('canvas'); off.width = sw; off.height = sh; const ctx = off.getContext('2d'); - if (!ctx) return ''; + if (!ctx) return new Uint8Array(); ctx.drawImage(full, sx, sy, sw, sh, 0, 0, sw, sh); - const dataUrl = off.toDataURL('image/png'); - return dataUrl.replace(/^data:image\/png;base64,/, ''); + return canvasToPng(off); } -function loadImage(src: string): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = src; - }); +async function pngToBitmap(png: Uint8Array): Promise { + try { + // Cast through BlobPart — Uint8Array satisfies the structural + // requirement at runtime, but TS's stricter ArrayBufferLike vs + // ArrayBuffer split (post-5.7) complains without help. + const blob = new Blob([png as BlobPart], { type: 'image/png' }); + return await createImageBitmap(blob); + } catch (err) { + console.warn('[pen] pngToBitmap failed', err); + return null; + } +} + +async function canvasToPng(canvas: HTMLCanvasElement): Promise { + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/png'), + ); + if (!blob) return new Uint8Array(); + const buf = await blob.arrayBuffer(); + return new Uint8Array(buf); } diff --git a/src/renderer/overlay/canvas/CommittedLayer.ts b/src/renderer/overlay/canvas/CommittedLayer.ts index 24112bb..e1d0a56 100644 --- a/src/renderer/overlay/canvas/CommittedLayer.ts +++ b/src/renderer/overlay/canvas/CommittedLayer.ts @@ -13,13 +13,13 @@ export class CommittedLayer { const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) throw new Error('CommittedLayer: 2D context unavailable'); this.ctx = ctx; - this.dpr = window.devicePixelRatio || 1; + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.resize(); } resize(): void { const { innerWidth: w, innerHeight: h } = window; - this.dpr = window.devicePixelRatio || 1; + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.canvas.width = Math.floor(w * this.dpr); this.canvas.height = Math.floor(h * this.dpr); this.canvas.style.width = `${w}px`; diff --git a/src/renderer/overlay/canvas/LiveLayer.ts b/src/renderer/overlay/canvas/LiveLayer.ts index 029f379..a692ae1 100644 --- a/src/renderer/overlay/canvas/LiveLayer.ts +++ b/src/renderer/overlay/canvas/LiveLayer.ts @@ -13,13 +13,21 @@ export class LiveLayer { const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) throw new Error('LiveLayer: 2D context unavailable'); this.ctx = ctx; - this.dpr = window.devicePixelRatio || 1; + // Floor at 2× so strokes stay crisp on standard-DPI external monitors + // (classroom IFPs are commonly 96 DPI / DPR 1) without ever downscaling + // a true Retina or higher display. ~4× canvas memory vs DPR=1, which + // is fine for one full-screen overlay. + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.resize(); } resize(): void { const { innerWidth: w, innerHeight: h } = window; - this.dpr = window.devicePixelRatio || 1; + // Floor at 2× so strokes stay crisp on standard-DPI external monitors + // (classroom IFPs are commonly 96 DPI / DPR 1) without ever downscaling + // a true Retina or higher display. ~4× canvas memory vs DPR=1, which + // is fine for one full-screen overlay. + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.canvas.width = Math.floor(w * this.dpr); this.canvas.height = Math.floor(h * this.dpr); this.canvas.style.width = `${w}px`; diff --git a/src/renderer/overlay/canvas/drawItem.ts b/src/renderer/overlay/canvas/drawItem.ts index 30ca52d..a589ed4 100644 --- a/src/renderer/overlay/canvas/drawItem.ts +++ b/src/renderer/overlay/canvas/drawItem.ts @@ -130,14 +130,20 @@ function drawStroke( end: { taper: 0, cap: true }, }; } else if (isPencil) { + // Tuned for fine-handwriting at sub-pixel widths: lower thinning so + // a 0.5–1px pencil doesn't get pinched into invisibility by + // perfect-freehand's outline algorithm, and lower streamline so the + // line actually follows the writer's wrist instead of being eaten + // by post-hoc smoothing. Tapers shrink proportionally so very fine + // strokes still end cleanly. opts = { - thinning: 0.08, - smoothing: 0.32, - streamline: 0.26, + thinning: 0.04, + smoothing: 0.28, + streamline: 0.18, easing: (t: number) => t, simulatePressure: false, - start: { taper: Math.min(effectiveWidth * 0.6, 6), cap: true }, - end: { taper: Math.min(effectiveWidth * 0.9, 10), cap: true }, + start: { taper: Math.min(effectiveWidth * 0.5, 4), cap: true }, + end: { taper: Math.min(effectiveWidth * 0.7, 7), cap: true }, }; } else { // pen @@ -256,14 +262,32 @@ function drawRegion( const w = Math.abs(item.p2.x - item.p1.x); const h = Math.abs(item.p2.y - item.p1.y); ctx.save(); - ctx.globalAlpha = item.opacity * 0.18; - ctx.fillStyle = item.color; - ctx.fillRect(x, y, w, h); - ctx.globalAlpha = item.opacity; - ctx.strokeStyle = item.color; - ctx.setLineDash([4, 4]); - ctx.lineWidth = 1; - ctx.strokeRect(x, y, w, h); + if (item.marchingAnts) { + // Snip preview: two-pass marching ants — black underneath, white on + // top with a dash-offset so the alternation is visible on light + // AND dark surfaces. Half-pixel offset for crisp 1px lines. A faint + // dim wash inside indicates the captured region. + ctx.globalAlpha = 0.18; + ctx.fillStyle = '#000000'; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 1; + ctx.lineWidth = 1; + ctx.setLineDash([6, 4]); + ctx.strokeStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.strokeRect(x + 0.5, y + 0.5, w, h); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)'; + ctx.lineDashOffset = 5; + ctx.strokeRect(x + 0.5, y + 0.5, w, h); + } else { + ctx.globalAlpha = item.opacity * 0.18; + ctx.fillStyle = item.color; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = item.opacity; + ctx.strokeStyle = item.color; + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1; + ctx.strokeRect(x, y, w, h); + } ctx.restore(); } @@ -292,21 +316,59 @@ function drawEllipse( function drawArrow(ctx: CanvasRenderingContext2D, item: Extract): void { const dx = item.p2.x - item.p1.x; const dy = item.p2.y - item.p1.y; + const length = Math.hypot(dx, dy); + if (length < 1) return; const angle = Math.atan2(dy, dx); - const head = Math.max(8, item.width * 3); + + // Head geometry: parametric in both length and width, capped at 45% + // of total length so very short arrows don't become all head, and + // floored so very thin arrows still read as arrows. The 0.72 aspect + // ratio (width as fraction of length) gives a slender, "designed" + // silhouette rather than the chunky 90° triangle a fixed-angle head + // produces. Notch at 0.22 of head length pulls the back inward so + // the head reads as a swept chevron, not a flat-based pyramid. + const widthBoost = 1 + item.width / 30; + const headLen = Math.max(12, Math.min(length * 0.22 * widthBoost, length * 0.45)); + const headHalfW = headLen * 0.36; + const notchDepth = headLen * 0.22; + + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + const perpX = -sinA; + const perpY = cosA; + + const tipX = item.p2.x; + const tipY = item.p2.y; + const backX = item.p2.x - headLen * cosA; + const backY = item.p2.y - headLen * sinA; + const notchX = backX + notchDepth * cosA; + const notchY = backY + notchDepth * sinA; + const wingLX = backX + headHalfW * perpX; + const wingLY = backY + headHalfW * perpY; + const wingRX = backX - headHalfW * perpX; + const wingRY = backY - headHalfW * perpY; + ctx.save(); ctx.strokeStyle = item.color; ctx.fillStyle = item.color; ctx.lineWidth = item.width; ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Shaft stops at the notch — if we drew through to p2 the head fill + // would overlap the shaft's round cap and give small arrows a visible + // blob at the join. Ending at the notch makes the silhouette one + // continuous shape. ctx.beginPath(); ctx.moveTo(item.p1.x, item.p1.y); - ctx.lineTo(item.p2.x, item.p2.y); + ctx.lineTo(notchX, notchY); ctx.stroke(); + ctx.beginPath(); - ctx.moveTo(item.p2.x, item.p2.y); - ctx.lineTo(item.p2.x - head * Math.cos(angle - Math.PI / 7), item.p2.y - head * Math.sin(angle - Math.PI / 7)); - ctx.lineTo(item.p2.x - head * Math.cos(angle + Math.PI / 7), item.p2.y - head * Math.sin(angle + Math.PI / 7)); + ctx.moveTo(tipX, tipY); + ctx.lineTo(wingLX, wingLY); + ctx.lineTo(notchX, notchY); + ctx.lineTo(wingRX, wingRY); ctx.closePath(); ctx.fill(); ctx.restore(); diff --git a/src/renderer/overlay/index.html b/src/renderer/overlay/index.html index 8a3c9ef..bc56f06 100644 --- a/src/renderer/overlay/index.html +++ b/src/renderer/overlay/index.html @@ -58,6 +58,59 @@ min-width: 160px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); } + /* Snip action menu — Copy / Save / Cancel pinned to the + bottom-right of a completed snip selection. Sits ABOVE the + capture-surface in DOM order so clicks land on buttons rather + than the underlying drawing surface; we also stopPropagation + on pointerdown defensively. */ + .snip-actions { + position: absolute; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px; + background: rgba(20, 20, 22, 0.94); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); + font-size: 12px; + line-height: 1; + z-index: 50; + animation: snipActionsIn 0.14s cubic-bezier(0.2, 0.7, 0.2, 1); + } + @keyframes snipActionsIn { + from { opacity: 0; transform: translateY(-2px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + .snip-action { + appearance: none; + background: transparent; + color: #e4e4e6; + border: 1px solid transparent; + border-radius: 5px; + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, color 0.12s; + } + .snip-action:hover { background: rgba(255, 255, 255, 0.08); } + .snip-action:active { background: rgba(255, 255, 255, 0.14); } + .snip-action[disabled] { opacity: 0.55; cursor: progress; } + .snip-action[disabled]:hover { background: transparent; } + .snip-action-primary[disabled]:hover { filter: none; } + .snip-action-primary { + background: linear-gradient(180deg, #d8b572 0%, #b89150 100%); + color: #1c1c1e; + font-weight: 600; + } + .snip-action-primary:hover { filter: brightness(1.05); } + .snip-action-quiet { + color: rgba(228, 228, 230, 0.65); + padding: 6px 8px; + font-size: 13px; + } + .snip-action-quiet:hover { color: #e4e4e6; } diff --git a/src/renderer/overlay/tools/snip.ts b/src/renderer/overlay/tools/snip.ts index b382c1a..043df29 100644 --- a/src/renderer/overlay/tools/snip.ts +++ b/src/renderer/overlay/tools/snip.ts @@ -2,9 +2,6 @@ import type { RegionShape } from '../../../shared/types'; import type { Tool } from './types'; import { nextId } from './types'; -const PREVIEW_COLOR = '#ffffff'; -const PREVIEW_OPACITY = 0.9; - export const snip: Tool = (() => { let draft: RegionShape | null = null; let anchor: { x: number; y: number } | null = null; @@ -21,8 +18,12 @@ export const snip: Tool = (() => { id: nextId('snip-preview'), p1: anchor, p2: anchor, - color: PREVIEW_COLOR, - opacity: PREVIEW_OPACITY, + // Color/opacity are ignored when marchingAnts is set — the + // renderer paints its own high-contrast B/W pattern. Kept here + // for type-shape compatibility with RegionShape. + color: '#ffffff', + opacity: 1, + marchingAnts: true, }; ctx.setDraft(draft); }, diff --git a/src/renderer/penApi.d.ts b/src/renderer/penApi.d.ts index 0c53868..5665557 100644 --- a/src/renderer/penApi.d.ts +++ b/src/renderer/penApi.d.ts @@ -1,4 +1,4 @@ -import type { HubStateUpdate } from '../shared/types'; +import type { HubStateUpdate, ScreenPermissionStatus } from '../shared/types'; declare global { interface Window { @@ -12,10 +12,10 @@ declare global { onUndo(cb: () => void): () => void; onRedo(cb: () => void): () => void; onClear(cb: () => void): () => void; - onScreenshot(cb: (payload: { dataUrl: string }) => void): () => void; + onScreenshot(cb: (payload: { png: Uint8Array }) => void): () => void; onSnip( cb: (payload: { - dataUrl: string; + png: Uint8Array; rect: { x: number; y: number; w: number; h: number }; scaleFactor: number; }) => void, @@ -25,8 +25,8 @@ declare global { ): () => void; requestFocus(): Promise; releaseFocus(): Promise; - sendScreenshotResult(pngBase64: string): Promise; - sendSnipResult(pngBase64: string): Promise; + sendScreenshotResult(png: Uint8Array): Promise; + sendSnipResult(png: Uint8Array): Promise; }; snip: { set(payload: { @@ -50,11 +50,29 @@ declare global { setContentSize(payload: { axis: 'h' | 'v'; size: number }): Promise; }; permissions: { - check(): Promise<{ screen: string; accessibility: boolean }>; + check(): Promise<{ screen: ScreenPermissionStatus; accessibility: boolean }>; + deepCheck(): Promise<{ screen: ScreenPermissionStatus; probeError: boolean }>; open(which: 'screen' | 'accessibility'): Promise; + onNeeded(cb: (payload: { reason: 'screen' }) => void): () => void; + onStatus( + cb: (payload: { screen: ScreenPermissionStatus; probeError?: boolean }) => void, + ): () => void; + }; + capture: { + onSaved(cb: (payload: { path: string }) => void): () => void; + onError( + cb: (payload: { message: string; recoverable: boolean }) => void, + ): () => void; + }; + settings: { + pickSaveDir(): Promise; + }; + shell: { + openPath(p: string): Promise; }; app: { - info(): Promise<{ name: string; version: string }>; + info(): Promise<{ name: string; version: string; packaged: boolean }>; + relaunch(): Promise; }; env: { displayId(): number; diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 40fe4f3..403c099 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -11,6 +11,11 @@ import type { } from '../../shared/types'; import { Icons, Logo } from './icons'; +// Status-panel discriminator. Both kinds reuse the existing +// .settings-panel layout slot so they feel native to the toolbar +// instead of floating as an out-of-place modal. +type PanelKind = 'permission' | 'error'; + interface HubSnapshot { activeTool: ToolId; drawMode: boolean; @@ -23,6 +28,9 @@ interface HubSnapshot { settingsOpen: boolean; thicknessFlyoutOpen: boolean; perToolWidth: { pencil: number; pen: number; eraser: number; highlighter: number }; + saveDir: string | null; + alwaysAskSavePath: boolean; + statusPanelOpen: boolean; } type FlyoutTool = 'pencil' | 'pen' | 'eraser' | 'highlighter'; @@ -60,6 +68,36 @@ const TOOL_BY_ID: Record = ALL_TOOLS.reduce( {} as Record, ); +// Permission-panel hint copy. When probeError=true, desktopCapturer +// outright threw on the recheck attempt — the process can't pick the +// new TCC state up without a relaunch. +function stuckHint(probeError: boolean): string { + if (probeError) { + return ( + "macOS can't refresh the permission for a running process — " + + 'Click Relaunch to restart Lekhini and pick up the change.' + ); + } + return ( + "Still off. Make sure Lekhini is toggled on under Privacy & Security " + + '→ Screen Recording, then click Recheck.' + ); +} + +// Display-friendly path: replace the home dir with `~` and ellipsize +// the middle if the result is still long. Pure cosmetic — the toast +// is narrow and a full POSIX path overflows. +function shortenPath(p: string, max = 56): string { + let s = p; + // Best-effort home detection — `process.env.HOME` is not available + // in the renderer; fall back to the common macOS / Linux prefix. + const home = /^\/Users\/[^/]+/.exec(s)?.[0] ?? /^\/home\/[^/]+/.exec(s)?.[0]; + if (home && s.startsWith(home)) s = '~' + s.slice(home.length); + if (s.length <= max) return s; + const tail = s.slice(-(max - 3)); + return '…' + tail; +} + export function ToolbarApp() { const [hub, setHub] = createSignal({ activeTool: 'pencil', @@ -73,13 +111,38 @@ export function ToolbarApp() { settingsOpen: false, thicknessFlyoutOpen: false, perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 }, + saveDir: null, + alwaysAskSavePath: false, + statusPanelOpen: false, }); + // Status-panel state. Mutually exclusive with the settings panel — + // when one opens, the layout slot belongs to it. `panelError` holds + // the message body when panelKind === 'error'; `panelHint` is a + // small inline note shown under the body after a manual Recheck + // returns the same denied status. + const [panelKind, setPanelKind] = createSignal(null); + const [panelError, setPanelError] = createSignal(null); + const [panelHint, setPanelHint] = createSignal(null); + // True after a recheck attempt where desktopCapturer.getSources() + // outright threw — process is stuck until relaunch. Drives the + // panel to promote the Relaunch button over Recheck. + const [permStuck, setPermStuck] = createSignal(false); + // Set by capture:saved so the titlebar hint becomes a clickable + // 'Reveal' that opens the file's folder. Cleared on next hover hint + // or after revealMs. + const [revealPath, setRevealPath] = createSignal(null); + let revealTimer: number | null = null; const [platform, setPlatform] = createSignal('darwin'); const [hint, setHint] = createSignal(''); const [settingsOnLeft, setSettingsOnLeft] = createSignal(false); - const [appInfo, setAppInfo] = createSignal<{ name: string; version: string }>({ + const [appInfo, setAppInfo] = createSignal<{ + name: string; + version: string; + packaged: boolean; + }>({ name: 'Lekhini', version: '1.0.0', + packaged: true, }); let scrollRef: HTMLDivElement | undefined; let barMainRef: HTMLDivElement | undefined; @@ -101,6 +164,60 @@ export function ToolbarApp() { }); onCleanup(off); + // ── Permission + capture event wiring ──────────────────────── + // The main process emits 'permissions:needed' when a capture can't + // proceed, and 'capture:saved' / 'capture:error' after each + // attempt. We surface permission + error states as side panels + // (same slot as Settings), and successful saves as a brief + // clickable hint in the titlebar — much calmer than a floating + // toast inside a tiny toolbar window. + const offNeeded = window.pen.permissions.onNeeded(() => { + setPanelHint(null); + setPermStuck(false); + setPanelKind('permission'); + // Close settings if it was occupying the slot. + if (hub().settingsOpen) void window.pen.hub.update({ settingsOpen: false }); + }); + const offStatus = window.pen.permissions.onStatus((p) => { + if (p.screen === 'granted') { + setPanelKind((k) => (k === 'permission' ? null : k)); + setPanelHint(null); + setPermStuck(false); + } else if (panelKind() === 'permission') { + setPermStuck(!!p.probeError); + setPanelHint(stuckHint(!!p.probeError)); + } + }); + const offSaved = window.pen.capture.onSaved((p) => { + // If we were showing an error panel, the user just successfully + // saved (e.g. via 'Pick new folder') — close the panel. + setPanelKind((k) => (k === 'error' ? null : k)); + setPanelError(null); + // Inline confirmation in the titlebar hint, auto-clears at 4s. + setRevealPath(p.path); + setHint(`Saved · ${shortenPath(p.path)}`); + if (revealTimer !== null) window.clearTimeout(revealTimer); + revealTimer = window.setTimeout(() => { + setRevealPath(null); + setHint(''); + revealTimer = null; + }, 4000); + }); + const offError = window.pen.capture.onError((p) => { + setPanelError(p.message); + setPanelKind('error'); + if (hub().settingsOpen) void window.pen.hub.update({ settingsOpen: false }); + }); + window.addEventListener('focus', onWindowFocus); + onCleanup(() => { + offNeeded(); + offStatus(); + offSaved(); + offError(); + window.removeEventListener('focus', onWindowFocus); + if (revealTimer !== null) window.clearTimeout(revealTimer); + }); + const el = scrollRef; if (!el) return; @@ -182,16 +299,18 @@ export function ToolbarApp() { } let target = barMainHeight; - if (s.settingsOpen) { - const settingsPanel = barMainRef.parentElement?.querySelector( + // Settings panel and status panel both render with class + // .settings-panel; whichever is open occupies the dock slot. + if (s.settingsOpen || s.statusPanelOpen) { + const sidePanel = barMainRef.parentElement?.querySelector( '.settings-panel', ) as HTMLElement | null; - if (settingsPanel) { - const settingsHeight = settingsPanel.scrollHeight; + if (sidePanel) { + const sideHeight = sidePanel.scrollHeight; target = s.orientation === 'h' - ? barMainHeight + settingsHeight - : Math.max(barMainHeight, settingsHeight); + ? barMainHeight + sideHeight + : Math.max(barMainHeight, sideHeight); } } // 2px for the bar's 1px border on each side. @@ -209,10 +328,38 @@ export function ToolbarApp() { void s.orientation; void s.minimized; void s.settingsOpen; + void s.statusPanelOpen; void s.thicknessFlyoutOpen; void s.profile; void s.activeTool; - requestAnimationFrame(reportContentSize); + void panelKind(); + // First RAF catches the common case (single-frame layout). A + // second RAF after it covers transitions where the bar-main was + // just unmounted-then-remounted (notably restore from + // minimized) — children sometimes need an extra frame to lay out + // their final size, and without this the footer would render + // clipped below the window's content-size until the next + // unrelated re-measure. + requestAnimationFrame(() => { + reportContentSize(); + requestAnimationFrame(reportContentSize); + }); + }); + + // Refresh which side the panel sits on whenever a status panel opens + // (mirrors the same logic the settings-open broadcast uses). + createEffect(() => { + if (panelKind() !== null) refreshSide(); + }); + + // Push panelKind open/close into the hub so main resizes the + // toolbar window to fit. Avoid an immediate redundant patch on + // first mount where both sides are already false. + createEffect(() => { + const open = panelKind() !== null; + if (open !== hub().statusPanelOpen) { + void window.pen.hub.update({ statusPanelOpen: open }); + } }); // Whenever a side panel flips open, ask main which side of the screen @@ -263,6 +410,12 @@ export function ToolbarApp() { void window.pen.hub.update({ theme: hub().theme === 'dark' ? 'light' : 'dark' }); }; const setProfile = (p: ProfileId) => void window.pen.hub.update({ profile: p }); + const toggleAlwaysAsk = () => + void window.pen.hub.update({ alwaysAskSavePath: !hub().alwaysAskSavePath }); + const pickSaveDir = async () => { + const dir = (await window.pen.settings.pickSaveDir()) as string | null; + if (dir) void window.pen.hub.update({ saveDir: dir }); + }; const toggleSettings = () => { const next = !hub().settingsOpen; if (next) refreshSide(); @@ -270,6 +423,60 @@ export function ToolbarApp() { }; const closeSettings = () => void window.pen.hub.update({ settingsOpen: false }); + // Status-panel actions. panelKind drives the rendered content; + // hub.statusPanelOpen is the open/close flag main watches so it + // can grow/shrink the toolbar window to fit the panel (in v-mode + // the dock slot's width comes from main's resizeToolbar, not CSS). + // A createEffect below keeps them in sync — without this mirror + // the panel would render inside the 88px-wide v-mode bar and be + // effectively invisible. + const closePanel = () => { + setPanelKind(null); + setPanelError(null); + setPanelHint(null); + }; + const recheckPermission = async () => { + // Use the deep probe — macOS's getMediaAccessStatus caches the + // result per-process and a plain check() can keep reporting + // 'denied' for the whole session even after the user toggled the + // permission on in System Settings. The deep probe actually hits + // desktopCapturer and forces a TCC refresh. + setPanelHint('Checking…'); + const result = (await window.pen.permissions.deepCheck()) as { + screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + probeError: boolean; + }; + if (result.screen === 'granted') { + closePanel(); + return; + } + setPermStuck(result.probeError); + setPanelHint(stuckHint(result.probeError)); + }; + const openScreenPrefs = () => void window.pen.permissions.open('screen'); + const relaunchApp = () => void window.pen.app.relaunch(); + const pickFolderFromError = async () => { + const dir = (await window.pen.settings.pickSaveDir()) as string | null; + if (dir) { + void window.pen.hub.update({ saveDir: dir }); + closePanel(); + } + }; + // Auto-recheck when the toolbar window regains focus — typical + // path is user opens System Settings, toggles Lekhini on, comes + // back. We only fire this while the permission panel is up so we + // don't badger the user otherwise. + const onWindowFocus = () => { + if (panelKind() === 'permission') void recheckPermission(); + }; + + // Mirror the side-panel state into a CSS-friendly attribute so the + // existing layout rules (flex-direction switch in v-mode, etc.) + // apply uniformly whether settings or a status panel is open. + const sidePanelOpen = createMemo(() => + hub().settingsOpen || panelKind() !== null, + ); + const showHint = (text: string) => setHint(text); const clearHint = () => setHint(''); const isMac = createMemo(() => platform() === 'darwin'); @@ -278,16 +485,15 @@ export function ToolbarApp() { const allowed = new Set(PROFILES[hub().profile].tools); return ALL_TOOLS.filter((t) => allowed.has(t.id)); }); - const brandLine = () => { - if (hint()) return hint(); - if (hub().drawMode) return 'Draw mode active'; - return 'Lekhini'; - }; - - const vertHintLine = () => { + // Hint line for the footer. Hover-hint takes priority; otherwise we + // show the active tool's name so the footer is never empty in + // either orientation. brandLine / vertHintLine retained for any + // future use but the footer is the new home for hover text. + const footerHintLine = (): string => { if (hint()) return hint(); const active = TOOL_BY_ID[hub().activeTool]; - return active ? active.label : 'Lekhini'; + if (active) return active.label; + return hub().drawMode ? 'Drawing' : 'Idle'; }; return ( @@ -297,14 +503,22 @@ export function ToolbarApp() { data-min={hub().minimized ? 'true' : 'false'} data-platform={isMac() ? 'mac' : 'win'} data-theme={hub().theme} - data-settings-open={hub().settingsOpen ? 'true' : 'false'} + data-settings-open={sidePanelOpen() ? 'true' : 'false'} data-settings-side={settingsOnLeft() ? 'left' : 'right'} > - +
+ {/* Click target is the inner span (no-drag region). The + outer .mini is a thin drag border so the pill can + still be moved without expanding. */} +
} > @@ -332,20 +546,7 @@ export function ToolbarApp() {
- - - {brandLine()} +
@@ -356,12 +557,6 @@ export function ToolbarApp() { onMouseLeave={clearHint} title="Collapse" >{Icons.collapse()} -
- - -
-
- {vertHintLine()}
@@ -631,6 +802,47 @@ export function ToolbarApp() { + + {/* ─── FOOTER ─── hover-hint on the left, plus the + rarely-touched status-dot + settings on the right. Lives + at the bottom of bar-main so it doesn't take attention + away from the tools. The hint area is also where the + 'Saved · …/lekhini-…png' reveal link surfaces. */} + {/* ─── SETTINGS DROPDOWN ─── */} @@ -692,6 +904,31 @@ export function ToolbarApp() { +
+ +
+ Always ask where to save + +
+
+ Save folder + +
+
+
@@ -711,6 +948,119 @@ export function ToolbarApp() {
+ + {/* ─── STATUS PANEL (permission / save error) ────────────── + Reuses the .settings-panel layout slot so it docks like + the Settings panel and grows the toolbar window the same + way. Settings has render priority — we only show this + when the settings panel is closed. */} + +
+
+ + {panelKind() === 'permission' ? 'Screen Recording' : "Couldn't save screenshot"} + + +
+ + +
+
+ {Icons.camera()} +
+ Lekhini needs Screen Recording permission to capture annotated + screenshots.{' '} + + macOS controls this — toggle Lekhini on under Privacy & + Security → Screen Recording, then return here. Lekhini + retries automatically when you come back. + + + You denied the system prompt last time. Try the screenshot + button again to be asked once more. + +
+
+ +
+ {panelHint()} +
+
+
+ + + + + + + + + + + + + +
+ +
+ Dev mode — TCC quirks are normal here. The packaged + Lekhini build doesn't have this caching issue. +
+
+
+
+ + +
+
+ {Icons.clear()} +
{panelError() ?? 'Unknown error.'}
+
+
+ + +
+
+
+
+
); diff --git a/src/renderer/toolbar/icons.tsx b/src/renderer/toolbar/icons.tsx index dda1fce..1ac0574 100644 --- a/src/renderer/toolbar/icons.tsx +++ b/src/renderer/toolbar/icons.tsx @@ -1,109 +1,149 @@ import type { JSX } from 'solid-js'; +// Toolbar icon set, redrawn in the Phosphor Icons visual language +// (https://phosphoricons.com — MIT). Each glyph is a fresh +// implementation tailored to Lekhini's 22×22 toolbar slot, not a +// verbatim copy of any Phosphor SVG. Stroke = 1.4 to match Phosphor's +// "regular" weight at 24px equivalent. Pure currentColor; the toolbar +// theme decides the actual hue via CSS. + const SVG = (children: JSX.Element): JSX.Element => ( - + {children} ); export const Icons = { + // Pencil — tilted body with a filled eraser cap on the wide end + // and a ferrule line. The eraser cap is the visual signature that + // tells it apart from the pen at a glance. pencil: () => SVG( <> - {/* Pencil body with hex-style ferrule lines and a tip */} - - + + , ), + // Pen — slightly thicker tilted barrel with a FILLED triangular + // nib protruding at the writing tip. The filled nib is the + // signature that distinguishes it from the (eraser-capped) pencil. pen: () => SVG( <> - {/* Fountain pen: tapered body + visible nib slit */} - - - - + + + , ), + // Highlighter — square chisel head over a tapered shaft. highlighter: () => SVG( <> - - - + + + , ), + // Eraser — block-tip eraser with a base shadow line. eraser: () => SVG( <> - - + + + , ), + // Hand — four-finger open palm. hand: () => SVG( <> - - - - + + + + , ), - line: () => SVG(), + // Minus — single horizontal line. + line: () => SVG(), + // TrendUp — diagonal line with terminal dots. trendline: () => SVG( <> - - - + + + , ), + // Stacked horizontal lines — Fibonacci/retracement. fib: () => SVG( <> - - - - + + + + , ), - region: () => SVG(), + // Selection — rounded dashed square. + region: () => SVG(), + // Crop — four L-corners with a dashed inner outline. snip: () => SVG( <> - + , ), + // Circle — clean ellipse. ellipse: () => SVG(), + // Chalkboard — rounded frame with two stand legs. whiteboard: () => SVG( <> - - - + + + , ), + // ArrowRight — single shaft with a clean chevron tip. arrow: () => SVG( <> - + , ), + // TextT — capital T with serifs at top and base. text: () => SVG( <> - + , ), + // ArrowUUpLeft — undo arrow. undo: () => SVG( <> @@ -111,6 +151,7 @@ export const Icons = { , ), + // ArrowUUpRight — redo arrow. redo: () => SVG( <> @@ -118,36 +159,41 @@ export const Icons = { , ), + // Trash — clean lid + bin, no inner ribs. clear: () => SVG( <> - - - + + + , ), + // Camera — body + shutter + grip notch. camera: () => SVG( <> - + , ), pause: () => SVG( <> - - + + , ), - play: () => SVG(), + play: () => + SVG(), + // Rows / orientation — two stacked rounded bars. orient: () => SVG( <> - - + + , ), + // X — diagonals. close: () => SVG( <> @@ -156,67 +202,102 @@ export const Icons = { , ), minus: () => SVG(), + // Sun — circle + 8 short cardinal/diagonal rays (Phosphor regular style). sun: () => SVG( <> - - - - - - - - - + + + + + + + + + , ), moon: () => SVG(), + // Gear — simpler 6-tooth wheel + center hub. Phosphor's actual gear + // has 8 teeth at this size; 6 reads cleaner in the 22px slot. gear: () => SVG( <> - + + + + + + + + , ), - check: () => - SVG(), + check: () => SVG(), + // Lines — three stacked horizontal bars, varying weight. thickness: () => SVG( <> - {/* Three stacked lines of increasing weight — a universal */} - {/* "thickness" / "stroke weight" icon. */} - - - + + + , ), + // ArrowsInSimple — two opposing chevrons. collapse: () => SVG( <> - {/* Inward-pointing chevrons — "minimize / collapse". */} , ), }; -export const Logo = (): JSX.Element => ( - - - - - - - - - - - -); +// Look up the user-supplied logo at build/icon.png via Vite's +// import.meta.glob. This does NOT fail the build when the file is +// absent — the glob simply returns an empty object — so the project +// keeps building until the user drops their PNG in. As soon as +// build/icon.png exists, Vite picks it up on the next dev refresh / +// build and the inline SVG fallback below is bypassed. +const _logoMods = import.meta.glob('../../../build/icon.png', { + eager: true, + query: '?url', + import: 'default', +}) as Record; +const LOGO_PNG_URL: string | undefined = Object.values(_logoMods)[0]; +export const Logo = (): JSX.Element => { + if (LOGO_PNG_URL) { + return ( + Lekhini + ); + } + // Fallback — same gold-pencil mark used since the project was + // initialised. Stays in place until build/icon.png is added. + return ( + + + + + + + + + + + + ); +}; diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index 4be445b..0d1fb36 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -157,16 +157,17 @@ html, body { pointer-events: none; } .tb-center .logo { display: inline-flex; align-items: center; } -.tb-center .hint { - font-size: 11px; - color: var(--hint); - letter-spacing: 0.02em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - transition: color 0.15s; -} -.tb-center .hint.has-hint { color: var(--hint-accent); font-weight: 500; } +/* h-mode logo: bigger now that nothing else sits in the titlebar + center. Subtle hover scale + soft glow when drawing so it doubles + as a brand mark and a 'is this thing working' affordance. */ +.tb-center .logo.big svg, +.tb-center .logo.big img { width: 32px; height: 32px; border-radius: 7px; } +.tb-center .logo.big { + transition: transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); +} +.tb-center .logo.big:hover { transform: scale(1.06); } +/* (Hover-hint text moved out of the titlebar into the new + .bar-footer at the bottom of the bar — see footer rules below.) */ /* ── Window controls (generic / Windows) ── */ @@ -256,7 +257,15 @@ html, body { flex-shrink: 0; } .v-brand .logo { display: inline-flex; } -.v-brand .logo svg { width: 24px; height: 24px; } +/* v-mode logo: even bigger now that brand strip carries only the + logo (settings + status-dot moved to footer). The vertical + toolbar is 88px wide so 40px works comfortably with breathing + room. */ +.v-brand .logo svg, .v-brand .logo img { width: 40px; height: 40px; border-radius: 9px; } +.v-brand .logo { + transition: transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); +} +.v-brand .logo:hover { transform: scale(1.06); } .v-theme-btn { -webkit-app-region: no-drag; @@ -276,33 +285,8 @@ html, body { .v-theme-btn:hover { background: var(--winctl-hover); color: var(--text); } .v-theme-btn svg { width: 12px; height: 12px; } -/* Vertical hover-hint strip (replacement for floating tooltips, which - get clipped by the narrow toolbar window). */ -.v-hint { - -webkit-app-region: drag; - flex-shrink: 0; - height: 30px; - padding: 0 5px; - font-size: 9.5px; - letter-spacing: 0.02em; - color: var(--hint); - background: var(--titlebar-bg); - border-bottom: 1px solid var(--separator); - transition: color 0.12s; - text-align: center; - line-height: 1.15; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; - align-items: center; - justify-content: center; -} -.v-hint.has-hint { - color: var(--hint-accent); - font-weight: 600; -} +/* (The old .v-hint strip was removed — the hover-hint now lives in + the bottom .bar-footer that's shared by both orientations.) */ /* ─────────────────── SCROLL AREA ─────────────────── */ @@ -410,7 +394,7 @@ html, body { .bar[data-orient='v'] .tools-zone, .bar[data-orient='v'] .actions-zone { display: grid; - grid-template-columns: repeat(2, 30px); + grid-template-columns: repeat(2, 26px); gap: 4px; background: var(--group-bg); border: 1px solid var(--group-border); @@ -449,9 +433,9 @@ html, body { background: transparent; color: var(--text); border: 1px solid transparent; - border-radius: 6px; - width: 30px; - height: 30px; + border-radius: 7px; + width: 26px; + height: 26px; display: inline-flex; align-items: center; justify-content: center; @@ -486,7 +470,7 @@ html, body { color: var(--gold); opacity: 1; } -.tool-btn svg, .action-btn svg { width: 19px; height: 19px; } +.tool-btn svg, .action-btn svg { width: 18px; height: 18px; } /* ─────────────────── PINNED COLOR / WIDTH ─────────────────── */ @@ -500,26 +484,26 @@ html, body { gap: 12px; } -.swatches { display: flex; gap: 5px; align-items: center; } +.swatches { display: flex; gap: 6px; align-items: center; } .swatch { - width: 18px; - height: 18px; - border-radius: 50%; - border: 1.5px solid var(--swatch-border); + width: 22px; + height: 22px; + border-radius: 5px; + border: 2px solid var(--swatch-border); cursor: pointer; - transition: transform 0.08s, border-color 0.12s, box-shadow 0.12s; + transition: transform 0.10s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.14s, box-shadow 0.14s; } -.swatch:hover { transform: scale(1.15); border-color: var(--swatch-border-hover); } +.swatch:hover { transform: scale(1.08); border-color: var(--swatch-border-hover); } .swatch.active { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(216, 181, 114, 0.30); } .hex-pick { position: relative; - width: 20px; - height: 20px; - border-radius: 50%; - border: 1.5px dashed var(--swatch-border); + width: 22px; + height: 22px; + border-radius: 5px; + border: 2px dashed var(--swatch-border); display: inline-flex; align-items: center; justify-content: center; @@ -590,28 +574,51 @@ html, body { } /* ─────────────────── MINIMIZED PILL ─────────────────── */ +/* Layout: outer .mini is the drag region (thin border around the + pill) so the user can still move the collapsed toolbar. The inner + .mini-logo fills almost the whole pill and is the click target + that restores — click events on drag regions are eaten by Electron, + so the previous setup (whole pill draggable, tiny logo span as the + only click target) made restoring fragile because users couldn't + reliably hit the 30px logo inside the 56px pill. */ .mini { width: 100%; height: 100%; + position: relative; + cursor: pointer; + -webkit-app-region: drag; +} +.mini-logo { + appearance: none; + background: transparent; + border: none; + padding: 0; + color: inherit; + font: inherit; + position: absolute; + inset: 5px; + -webkit-app-region: no-drag; display: flex; align-items: center; justify-content: center; + border-radius: 8px; cursor: pointer; - -webkit-app-region: drag; - position: relative; + transition: background 0.15s, transform 0.15s cubic-bezier(0.2, 0.7, 0.2, 1); } -.mini::after { - content: ''; - position: absolute; - top: 5px; - width: 22px; - height: 3px; - border-radius: 2px; - background: var(--separator); +.mini-logo:hover { + background: var(--hover); + transform: scale(1.04); +} +.mini-logo:active { transform: scale(0.97); } +.mini-logo svg, +.mini-logo img { + width: 36px; + height: 36px; + border-radius: 7px; + object-fit: contain; + pointer-events: none; /* clicks land on .mini-logo, not the image */ } -.mini-logo { -webkit-app-region: no-drag; display: inline-flex; } -.mini-logo svg { width: 30px; height: 30px; } /* ─────────────────── SETTINGS DROPDOWN ─────────────────── */ @@ -778,7 +785,7 @@ html, body { display: inline-flex; align-items: center; } -.about-logo svg { width: 28px; height: 28px; } +.about-logo svg, .about-logo img { width: 28px; height: 28px; border-radius: 6px; } .about-title-block { display: flex; flex-direction: column; @@ -935,3 +942,207 @@ html, body { border-color 0.16s, box-shadow 0.16s; } + +/* ─────────────────── STATUS PANEL (permission / error) ─────────────────── + Reuses .settings-panel for the dock + chrome; these rules style only + the inner content: icon + body, optional hint, action button row. + The 'is-reveal' modifier on the titlebar hint turns a successful-save + message into a clickable Reveal-in-Folder link. */ + +.status-icon-row { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.status-icon { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(216, 181, 114, 0.18); + color: var(--gold); + display: inline-flex; + align-items: center; + justify-content: center; +} +.status-icon svg { width: 18px; height: 18px; } +.status-icon-error { + background: rgba(231, 76, 60, 0.18); + color: #e74c3c; +} + +.status-body { + font-size: 11.5px; + line-height: 1.45; + color: var(--text); + flex: 1 1 auto; + min-width: 0; +} + +.status-hint { + font-size: 10.5px; + line-height: 1.4; + color: var(--hint); + padding: 6px 8px; + background: var(--group-bg); + border: 1px solid var(--group-border); + border-radius: 6px; +} + +/* When the recheck probe outright threw — process is stuck until + relaunch. Red-tinted hint draws attention to the Relaunch button. */ +.status-hint.is-stuck { + color: #e74c3c; + background: rgba(231, 76, 60, 0.10); + border-color: rgba(231, 76, 60, 0.30); +} + +/* Dev-mode footnote — small, italic, neutral. Disappears in packaged + builds where it's never relevant. */ +.status-footnote { + font-size: 10px; + line-height: 1.35; + color: var(--hint); + font-style: italic; + margin-top: 2px; +} + +.status-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 2px; +} + +.status-btn-primary { + background: linear-gradient(180deg, var(--gold) 0%, var(--gold-strong) 100%) !important; + color: var(--gold-text) !important; + border-color: rgba(0, 0, 0, 0.25) !important; + font-weight: 600; +} +.status-btn-primary:hover { filter: brightness(1.05); } + +/* Relaunch — clearly destructive-ish, dimmed so it doesn't compete + with the primary action but visible enough to be the escape hatch + when Recheck keeps reporting denied. */ +.status-btn-relaunch { + background: transparent !important; + border-color: rgba(231, 76, 60, 0.4) !important; + color: #e74c3c !important; +} +.status-btn-relaunch:hover { + background: rgba(231, 76, 60, 0.12) !important; +} + +/* Clickable success hint in the footer — when revealPath is set the + message becomes a Reveal link. Gold accent + pointer cursor. */ +.bar-footer-hint.is-reveal { + color: var(--gold) !important; + cursor: pointer; + -webkit-app-region: no-drag; + pointer-events: auto; + text-decoration: underline; + text-decoration-thickness: 1px; + text-decoration-color: rgba(216, 181, 114, 0.5); + text-underline-offset: 2px; +} +.bar-footer-hint.is-reveal:hover { + text-decoration-color: var(--gold); +} + +/* ─────────────────── FILE-SAVE SETTINGS ROW EXTRAS ─────────────────── */ + +.settings-row-stack { + flex-direction: column; + align-items: stretch; + gap: 4px; +} + +.settings-toggle-wide { + width: 100%; + justify-content: flex-start; +} + +.settings-toggle.on { + background: rgba(216, 181, 114, 0.18); + color: var(--gold); + border-color: rgba(216, 181, 114, 0.30); +} + +.settings-path { + font-family: "SF Mono", "JetBrains Mono", Menlo, monospace; + font-size: 10.5px; + letter-spacing: 0.01em; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ─────────────────── BAR FOOTER ─────────────────── */ +/* Bottom strip in both orientations carrying the hover-hint on the + left and the rarely-used status-dot + settings button on the right. + Moved here from the titlebar so the top of the toolbar is just the + logo + window controls, and incidental UI (like 'what does this + button do?' tooltips) doesn't compete with the brand mark. */ + +.bar-footer { + -webkit-app-region: drag; + flex-shrink: 0; + border-top: 1px solid var(--separator); + background: var(--footer-bg); + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; +} + +.bar-footer-hint { + flex: 1 1 auto; + min-width: 0; + font-size: 10.5px; + color: var(--hint); + letter-spacing: 0.01em; + transition: color 0.15s; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bar-footer-hint.has-hint { + color: var(--hint-accent); + font-weight: 500; +} + +.bar-footer-controls { + -webkit-app-region: no-drag; + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.bar-footer-controls .winctl.footer-settings { + width: 22px; + height: 22px; +} +.bar-footer-controls .winctl.footer-settings svg { width: 13px; height: 13px; } + +/* Vertical-mode footer needs to stack — the bar is 88px wide which + isn't enough room for hint text alongside two buttons in a row. */ +.bar[data-orient='v'] .bar-footer { + flex-direction: column; + gap: 4px; + padding: 6px 4px; +} +.bar[data-orient='v'] .bar-footer-hint { + white-space: normal; + text-align: center; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + font-size: 10px; +} +.bar[data-orient='v'] .bar-footer-controls { + justify-content: center; +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 049306e..7cd28d2 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -7,7 +7,7 @@ export const GRAPHITE_COLOR = '#3a3a3c'; export const DEFAULT_SETTINGS: ToolSettings = { color: GRAPHITE_COLOR, - width: 3, + width: 2, opacity: 1, }; @@ -23,17 +23,21 @@ export const SNAP_ANGLES_DEG = [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 1 // Initial sizes for the toolbar window. The renderer reports its // actual content size after mount and the window resizes to fit — so -// these values just need to be a reasonable first-paint estimate -// close to typical content size, minimizing the brief flicker before -// dynamic resize lands. +// these values just need to be a generous first-paint estimate close +// to typical content size, minimizing flicker before dynamic resize +// lands. CRITICAL: must be ≥ actual content height including the +// bottom footer, otherwise the footer is clipped below the visible +// window edge until the next state change triggers a remeasure. +// That's what bit users when they restored from the collapsed pill. // -// h.w is sized to fit teacher profile (11 tools) + actions + the -// inline color grid + right margin. h.h matches the typical bar -// height when the inline color grid is in place (it's taller than -// the icons row alone since the grid is 3 rows). +// Rough budget per orientation (sum to current values with margin): +// h: titlebar 28 + tools row 56 + footer 28 + borders 2 = ~114 +// → 140 leaves slack for taller tool rows +// v: v-controls 32 + v-brand 52 + tools ~280 + pinned ~96 + +// footer 52 + borders 2 = ~514 → 560 with slack export const TOOLBAR_SIZES = { - h: { w: 740, h: 102 }, - v: { w: 88, h: 480 }, + h: { w: 740, h: 140 }, + v: { w: 88, h: 560 }, }; // Extra space added when the settings dropdown is open. @@ -81,7 +85,7 @@ export const THICKNESS_PRESETS: Record< 'pencil' | 'pen' | 'eraser' | 'highlighter', number[] > = { - pencil: [1, 2, 3, 5, 8], + pencil: [0.5, 1, 2, 3, 6], pen: [2, 4, 8, 14, 22], eraser: [10, 18, 28, 44, 64], highlighter: [12, 18, 26, 34, 44], diff --git a/src/shared/types.ts b/src/shared/types.ts index 33fcbe2..e076d85 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -64,6 +64,10 @@ export interface RegionShape { p2: { x: number; y: number }; color: string; opacity: number; + // When set, render with the high-contrast B/W marching-ants pattern + // used for selection rectangles (snip preview). Without it the + // 1px-dashed white stroke is invisible against most desktops. + marchingAnts?: boolean; } export interface EllipseShape { @@ -137,6 +141,13 @@ export type HubStateUpdate = { settingsOpen?: boolean; thicknessFlyoutOpen?: boolean; perToolWidth?: Partial; + saveDir?: string | null; + alwaysAskSavePath?: boolean; + // Transient — not persisted. Mirrors whether the renderer is + // showing the status side panel (permission / save error) so + // main can resize the toolbar window to fit it, the same way it + // does for settingsOpen. + statusPanelOpen?: boolean; }; export type IpcChannel = @@ -154,6 +165,8 @@ export type IpcChannel = | 'capture:screenshot:result' | 'capture:snip:result' | 'capture:trigger' + | 'capture:saved' + | 'capture:error' | 'snip:set' | 'snip:clear' | 'snip:copy' @@ -167,4 +180,36 @@ export type IpcChannel = | 'toolbar:set-content-size' | 'app:info' | 'permissions:check' - | 'permissions:open'; + | 'permissions:open' + | 'permissions:needed' + | 'permissions:status' + | 'permissions:deep-recheck' + | 'app:relaunch' + | 'settings:save-dir:pick' + | 'shell:open-path'; + +export interface CaptureSaved { + path: string; +} + +export interface CaptureError { + message: string; + recoverable: boolean; +} + +export type PermissionReason = 'screen'; + +export interface PermissionNeeded { + reason: PermissionReason; +} + +export type ScreenPermissionStatus = + | 'granted' + | 'denied' + | 'not-determined' + | 'restricted' + | 'unknown'; + +export interface PermissionStatus { + screen: ScreenPermissionStatus; +} diff --git a/tsconfig.json b/tsconfig.json index ea6c2c4..64a6c52 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, "verbatimModuleSyntax": false, - "types": ["node"], + "types": ["node", "vite/client"], "baseUrl": ".", "paths": { "@shared/*": ["src/shared/*"],