mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-04 05:12:38 +02:00
refactor: fix dynamic tooltip resizing and split autocomplete into SPR modules
This commit is contained in:
parent
6899134a20
commit
9c1d9357c4
12 changed files with 326 additions and 193 deletions
244
surfsense_desktop/src/modules/autocomplete/index.ts
Normal file
244
surfsense_desktop/src/modules/autocomplete/index.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { clipboard, ipcMain, screen } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../ipc/channels';
|
||||
import { getFrontmostApp, hasAccessibilityPermission, simulatePaste } from '../platform';
|
||||
import { getMainWindow } from '../window';
|
||||
import {
|
||||
appendToBuffer, buildKeycodeMap, getBuffer, getBufferTrimmed,
|
||||
getLastTrackedApp, removeLastChar, resetBuffer, resolveChar, setLastTrackedApp,
|
||||
} from './keystroke-buffer';
|
||||
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
|
||||
|
||||
const DEBOUNCE_MS = 600;
|
||||
|
||||
let uIOhook: any = null;
|
||||
let UiohookKey: any = {};
|
||||
let IGNORED_KEYCODES: Set<number> = new Set();
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hookStarted = false;
|
||||
let autocompleteEnabled = true;
|
||||
let savedClipboard = '';
|
||||
let sourceApp = '';
|
||||
let pendingSuggestionText = '';
|
||||
|
||||
function loadUiohook(): boolean {
|
||||
if (uIOhook) return true;
|
||||
try {
|
||||
const mod = require('uiohook-napi');
|
||||
uIOhook = mod.uIOhook;
|
||||
UiohookKey = mod.UiohookKey;
|
||||
IGNORED_KEYCODES = new Set([
|
||||
UiohookKey.Shift, UiohookKey.ShiftRight,
|
||||
UiohookKey.Ctrl, UiohookKey.CtrlRight,
|
||||
UiohookKey.Alt, UiohookKey.AltRight,
|
||||
UiohookKey.Meta, UiohookKey.MetaRight,
|
||||
UiohookKey.CapsLock, UiohookKey.NumLock, UiohookKey.ScrollLock,
|
||||
UiohookKey.F1, UiohookKey.F2, UiohookKey.F3, UiohookKey.F4,
|
||||
UiohookKey.F5, UiohookKey.F6, UiohookKey.F7, UiohookKey.F8,
|
||||
UiohookKey.F9, UiohookKey.F10, UiohookKey.F11, UiohookKey.F12,
|
||||
UiohookKey.PrintScreen,
|
||||
]);
|
||||
buildKeycodeMap();
|
||||
console.log('[autocomplete] uiohook-napi loaded');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[autocomplete] Failed to load uiohook-napi:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDebounce(): void {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isSurfSenseWindow(): boolean {
|
||||
const app = getFrontmostApp();
|
||||
return app === 'Electron' || app === 'SurfSense' || app === 'surfsense-desktop';
|
||||
}
|
||||
|
||||
function onKeyDown(event: {
|
||||
keycode: number;
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
}): void {
|
||||
if (!autocompleteEnabled) return;
|
||||
|
||||
const currentApp = getFrontmostApp();
|
||||
if (currentApp !== getLastTrackedApp()) {
|
||||
resetBuffer();
|
||||
setLastTrackedApp(currentApp);
|
||||
}
|
||||
|
||||
const win = getSuggestionWindow();
|
||||
|
||||
if (event.keycode === UiohookKey.Tab && win && !win.isDestroyed()) {
|
||||
if (pendingSuggestionText) {
|
||||
acceptAndInject(pendingSuggestionText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.keycode === UiohookKey.Escape) {
|
||||
if (win && !win.isDestroyed()) {
|
||||
destroySuggestion();
|
||||
pendingSuggestionText = '';
|
||||
}
|
||||
clearDebounce();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentApp === 'Electron' || currentApp === 'SurfSense' || currentApp === 'surfsense-desktop') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
resetBuffer();
|
||||
clearDebounce();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.keycode === UiohookKey.Backspace) {
|
||||
removeLastChar();
|
||||
} else if (event.keycode === UiohookKey.Delete) {
|
||||
// forward delete doesn't affect our trailing buffer
|
||||
} else if (event.keycode === UiohookKey.Enter) {
|
||||
appendToBuffer('\n');
|
||||
} else if (event.keycode === UiohookKey.Space) {
|
||||
appendToBuffer(' ');
|
||||
} else if (
|
||||
event.keycode === UiohookKey.ArrowLeft || event.keycode === UiohookKey.ArrowRight ||
|
||||
event.keycode === UiohookKey.ArrowUp || event.keycode === UiohookKey.ArrowDown ||
|
||||
event.keycode === UiohookKey.Home || event.keycode === UiohookKey.End ||
|
||||
event.keycode === UiohookKey.PageUp || event.keycode === UiohookKey.PageDown
|
||||
) {
|
||||
resetBuffer();
|
||||
clearDebounce();
|
||||
return;
|
||||
} else if (IGNORED_KEYCODES.has(event.keycode)) {
|
||||
return;
|
||||
} else {
|
||||
const ch = resolveChar(event.keycode, !!event.shiftKey);
|
||||
if (ch) appendToBuffer(ch);
|
||||
}
|
||||
|
||||
if (win && !win.isDestroyed()) {
|
||||
destroySuggestion();
|
||||
}
|
||||
|
||||
clearDebounce();
|
||||
debounceTimer = setTimeout(() => {
|
||||
triggerAutocomplete();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function onMouseClick(): void {
|
||||
resetBuffer();
|
||||
}
|
||||
|
||||
async function triggerAutocomplete(): Promise<void> {
|
||||
if (!hasAccessibilityPermission()) return;
|
||||
if (isSurfSenseWindow()) return;
|
||||
|
||||
const text = getBufferTrimmed();
|
||||
if (!text || text.length < 5) return;
|
||||
|
||||
sourceApp = getFrontmostApp();
|
||||
savedClipboard = clipboard.readText();
|
||||
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const win = createSuggestionWindow(cursor.x, cursor.y);
|
||||
|
||||
let searchSpaceId = '1';
|
||||
const mainWin = getMainWindow();
|
||||
if (mainWin && !mainWin.isDestroyed()) {
|
||||
const mainUrl = mainWin.webContents.getURL();
|
||||
const match = mainUrl.match(/\/dashboard\/(\d+)/);
|
||||
if (match) {
|
||||
searchSpaceId = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
const sw = getSuggestionWindow();
|
||||
setTimeout(() => {
|
||||
if (sw && !sw.isDestroyed()) {
|
||||
sw.webContents.send(IPC_CHANNELS.AUTOCOMPLETE_CONTEXT, {
|
||||
text: getBuffer(),
|
||||
cursorPosition: getBuffer().length,
|
||||
searchSpaceId,
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
async function acceptAndInject(text: string): Promise<void> {
|
||||
if (!sourceApp) return;
|
||||
if (!hasAccessibilityPermission()) return;
|
||||
|
||||
clipboard.writeText(text);
|
||||
destroySuggestion();
|
||||
pendingSuggestionText = '';
|
||||
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
simulatePaste();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
clipboard.writeText(savedClipboard);
|
||||
appendToBuffer(text);
|
||||
} catch {
|
||||
clipboard.writeText(savedClipboard);
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => {
|
||||
await acceptAndInject(text);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.DISMISS_SUGGESTION, () => {
|
||||
destroySuggestion();
|
||||
pendingSuggestionText = '';
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SUGGESTION_TEXT, (_event, text: string) => {
|
||||
pendingSuggestionText = text;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.SET_AUTOCOMPLETE_ENABLED, (_event, enabled: boolean) => {
|
||||
autocompleteEnabled = enabled;
|
||||
if (!enabled) {
|
||||
clearDebounce();
|
||||
destroySuggestion();
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.GET_AUTOCOMPLETE_ENABLED, () => autocompleteEnabled);
|
||||
}
|
||||
|
||||
export function registerAutocomplete(): void {
|
||||
registerIpcHandlers();
|
||||
|
||||
if (!loadUiohook()) {
|
||||
console.error('[autocomplete] Cannot start: uiohook-napi failed to load');
|
||||
return;
|
||||
}
|
||||
|
||||
uIOhook.on('keydown', onKeyDown);
|
||||
uIOhook.on('click', onMouseClick);
|
||||
try {
|
||||
uIOhook.start();
|
||||
hookStarted = true;
|
||||
} catch (err) {
|
||||
console.error('[autocomplete] uIOhook.start() failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterAutocomplete(): void {
|
||||
clearDebounce();
|
||||
destroySuggestion();
|
||||
if (uIOhook && hookStarted) {
|
||||
try { uIOhook.stop(); } catch { /* already stopped */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
const MAX_BUFFER_LENGTH = 4000;
|
||||
const KEYCODE_TO_CHAR: Record<number, [string, string]> = {};
|
||||
|
||||
let keystrokeBuffer = '';
|
||||
let lastTrackedApp = '';
|
||||
|
||||
export function buildKeycodeMap(): void {
|
||||
const letters: [string, number][] = [
|
||||
['q', 16], ['w', 17], ['e', 18], ['r', 19], ['t', 20],
|
||||
['y', 21], ['u', 22], ['i', 23], ['o', 24], ['p', 25],
|
||||
['a', 30], ['s', 31], ['d', 32], ['f', 33], ['g', 34],
|
||||
['h', 35], ['j', 36], ['k', 37], ['l', 38],
|
||||
['z', 44], ['x', 45], ['c', 46], ['v', 47],
|
||||
['b', 48], ['n', 49], ['m', 50],
|
||||
];
|
||||
for (const [ch, code] of letters) {
|
||||
KEYCODE_TO_CHAR[code] = [ch, ch.toUpperCase()];
|
||||
}
|
||||
|
||||
const digits: [string, string, number][] = [
|
||||
['1', '!', 2], ['2', '@', 3], ['3', '#', 4], ['4', '$', 5],
|
||||
['5', '%', 6], ['6', '^', 7], ['7', '&', 8], ['8', '*', 9],
|
||||
['9', '(', 10], ['0', ')', 11],
|
||||
];
|
||||
for (const [norm, shifted, code] of digits) {
|
||||
KEYCODE_TO_CHAR[code] = [norm, shifted];
|
||||
}
|
||||
|
||||
const punctuation: [string, string, number][] = [
|
||||
[';', ':', 39], ['=', '+', 13], [',', '<', 51], ['-', '_', 12],
|
||||
['.', '>', 52], ['/', '?', 53], ['`', '~', 41], ['[', '{', 26],
|
||||
['\\', '|', 43], [']', '}', 27], ["'", '"', 40],
|
||||
];
|
||||
for (const [norm, shifted, code] of punctuation) {
|
||||
KEYCODE_TO_CHAR[code] = [norm, shifted];
|
||||
}
|
||||
}
|
||||
|
||||
export function resetBuffer(): void {
|
||||
keystrokeBuffer = '';
|
||||
}
|
||||
|
||||
export function appendToBuffer(char: string): void {
|
||||
keystrokeBuffer += char;
|
||||
if (keystrokeBuffer.length > MAX_BUFFER_LENGTH) {
|
||||
keystrokeBuffer = keystrokeBuffer.slice(-MAX_BUFFER_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeLastChar(): void {
|
||||
if (keystrokeBuffer.length > 0) {
|
||||
keystrokeBuffer = keystrokeBuffer.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuffer(): string {
|
||||
return keystrokeBuffer;
|
||||
}
|
||||
|
||||
export function getBufferTrimmed(): string {
|
||||
return keystrokeBuffer.trim();
|
||||
}
|
||||
|
||||
export function getLastTrackedApp(): string {
|
||||
return lastTrackedApp;
|
||||
}
|
||||
|
||||
export function setLastTrackedApp(app: string): void {
|
||||
lastTrackedApp = app;
|
||||
}
|
||||
|
||||
export function resolveChar(keycode: number, shift: boolean): string | null {
|
||||
const mapping = KEYCODE_TO_CHAR[keycode];
|
||||
if (!mapping) return null;
|
||||
return shift ? mapping[1] : mapping[0];
|
||||
}
|
||||
103
surfsense_desktop/src/modules/autocomplete/suggestion-window.ts
Normal file
103
surfsense_desktop/src/modules/autocomplete/suggestion-window.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { BrowserWindow, screen, shell } from 'electron';
|
||||
import path from 'path';
|
||||
import { getServerPort } from '../server';
|
||||
|
||||
const TOOLTIP_WIDTH = 420;
|
||||
const TOOLTIP_HEIGHT = 38;
|
||||
const MAX_HEIGHT = 400;
|
||||
|
||||
let suggestionWindow: BrowserWindow | null = null;
|
||||
let resizeTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } {
|
||||
const display = screen.getDisplayNearestPoint({ x, y });
|
||||
const { x: dx, y: dy, width: dw, height: dh } = display.workArea;
|
||||
return {
|
||||
x: Math.max(dx, Math.min(x, dx + dw - w)),
|
||||
y: Math.max(dy, Math.min(y, dy + dh - h)),
|
||||
};
|
||||
}
|
||||
|
||||
function stopResizePolling(): void {
|
||||
if (resizeTimer) { clearInterval(resizeTimer); resizeTimer = null; }
|
||||
}
|
||||
|
||||
function startResizePolling(win: BrowserWindow): void {
|
||||
stopResizePolling();
|
||||
let lastH = 0;
|
||||
resizeTimer = setInterval(async () => {
|
||||
if (!win || win.isDestroyed()) { stopResizePolling(); return; }
|
||||
try {
|
||||
const h: number = await win.webContents.executeJavaScript(
|
||||
`document.body.scrollHeight`
|
||||
);
|
||||
if (h > 0 && h !== lastH) {
|
||||
lastH = h;
|
||||
const clamped = Math.min(h, MAX_HEIGHT);
|
||||
const bounds = win.getBounds();
|
||||
win.setBounds({ x: bounds.x, y: bounds.y, width: TOOLTIP_WIDTH, height: clamped });
|
||||
}
|
||||
} catch {}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
export function getSuggestionWindow(): BrowserWindow | null {
|
||||
return suggestionWindow;
|
||||
}
|
||||
|
||||
export function destroySuggestion(): void {
|
||||
stopResizePolling();
|
||||
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
|
||||
suggestionWindow.close();
|
||||
}
|
||||
suggestionWindow = null;
|
||||
}
|
||||
|
||||
export function createSuggestionWindow(x: number, y: number): BrowserWindow {
|
||||
destroySuggestion();
|
||||
|
||||
const pos = clampToScreen(x, y + 20, TOOLTIP_WIDTH, TOOLTIP_HEIGHT);
|
||||
|
||||
suggestionWindow = new BrowserWindow({
|
||||
width: TOOLTIP_WIDTH,
|
||||
height: TOOLTIP_HEIGHT,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
focusable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: true,
|
||||
type: 'panel',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '..', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
show: false,
|
||||
});
|
||||
|
||||
suggestionWindow.loadURL(`http://localhost:${getServerPort()}/desktop/suggestion?t=${Date.now()}`);
|
||||
|
||||
suggestionWindow.once('ready-to-show', () => {
|
||||
suggestionWindow?.showInactive();
|
||||
if (suggestionWindow) startResizePolling(suggestionWindow);
|
||||
});
|
||||
|
||||
suggestionWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http://localhost')) {
|
||||
return { action: 'allow' };
|
||||
}
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
suggestionWindow.on('closed', () => {
|
||||
stopResizePolling();
|
||||
suggestionWindow = null;
|
||||
});
|
||||
|
||||
return suggestionWindow;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue