mirror of
https://github.com/Melorise/TamaASR.git
synced 2026-11-07 14:39:32 +00:00
feat: 完成托盘设置页和悬浮球交互
实现隐藏启动、就绪状态托盘、登录提醒、后端切换、快捷键与自启动设置、九宫格和坐标微调,以及常驻悬浮球点击录音。 AI-assisted-by: Codex AI-model: gpt-5.6-sol AI-reasoning-effort: medium Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
function desktopEscape(value: string): string {
|
||||||
|
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('`', '\\`').replaceAll('$', '\\$')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutoStartService {
|
||||||
|
private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'meloasr.desktop');
|
||||||
|
|
||||||
|
apply(enabled: boolean): void {
|
||||||
|
if (!enabled) {
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(this.filePath, [
|
||||||
|
'[Desktop Entry]',
|
||||||
|
'Type=Application',
|
||||||
|
'Name=MeloASR',
|
||||||
|
'Hidden=true',
|
||||||
|
''
|
||||||
|
].join('\n'), { mode: 0o644 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const args = app.isPackaged ? [] : [app.getAppPath()];
|
||||||
|
const command = [process.execPath, ...args].map(desktopEscape).join(' ');
|
||||||
|
const contents = [
|
||||||
|
'[Desktop Entry]',
|
||||||
|
'Type=Application',
|
||||||
|
'Name=MeloASR',
|
||||||
|
`Exec=${command}`,
|
||||||
|
'Terminal=false',
|
||||||
|
'X-GNOME-Autostart-enabled=true',
|
||||||
|
'Comment=MeloASR',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(this.filePath, contents, { mode: 0o644 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { app, ipcMain } from 'electron';
|
||||||
|
import { AutoStartService } from './auto-start-service';
|
||||||
|
import { BackendManager } from './backend-manager';
|
||||||
|
import { FcitxBridge } from './fcitx-bridge';
|
||||||
|
import { OverlayController } from './overlay-controller';
|
||||||
|
import { SessionController } from './session-controller';
|
||||||
|
import { SettingsStore } from './settings-store';
|
||||||
|
import { SettingsWindow } from './settings-window';
|
||||||
|
import { TrayController } from './tray-controller';
|
||||||
|
import type { BackendStatusPayload, Point } from '../shared/contracts';
|
||||||
|
|
||||||
|
app.setName('MeloASR');
|
||||||
|
const singleInstance = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
|
if (!singleInstance) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
void app.whenReady().then(bootstrap).catch((error) => {
|
||||||
|
console.error('[MeloASR 启动失败]', error);
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const settings = new SettingsStore();
|
||||||
|
const currentSettings = settings.load();
|
||||||
|
const autoStart = new AutoStartService();
|
||||||
|
try { autoStart.apply(currentSettings.autoStart); }
|
||||||
|
catch (error) { console.error('[开机自启动设置失败]', error); }
|
||||||
|
|
||||||
|
const bridge = new FcitxBridge();
|
||||||
|
bridge.configure(currentSettings.shortcut);
|
||||||
|
const backends = new BackendManager(settings);
|
||||||
|
const overlay = new OverlayController(settings);
|
||||||
|
await overlay.create();
|
||||||
|
const sessions = new SessionController(backends, bridge, overlay);
|
||||||
|
const repositoryUrl = process.env.MELOASR_REPOSITORY_URL?.trim() || null;
|
||||||
|
const settingsWindow = new SettingsWindow(settings, backends, overlay, sessions, repositoryUrl);
|
||||||
|
const settingsWindowReady = settingsWindow.create();
|
||||||
|
registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow });
|
||||||
|
await settingsWindowReady;
|
||||||
|
const tray = new TrayController(
|
||||||
|
backends,
|
||||||
|
settingsWindow,
|
||||||
|
() => { app.relaunch(); app.exit(0); },
|
||||||
|
() => app.quit()
|
||||||
|
);
|
||||||
|
tray.create();
|
||||||
|
|
||||||
|
bridge.on('request-start', () => void sessions.start());
|
||||||
|
bridge.on('request-stop', () => sessions.stop());
|
||||||
|
bridge.on('request-cancel', () => sessions.cancel());
|
||||||
|
bridge.on('disconnect', () => sessions.cancel('Fcitx5 插件连接已断开'));
|
||||||
|
bridge.on('connect', () => settingsWindow.publish());
|
||||||
|
bridge.on('protocol-error', (error) => console.error('[Fcitx5 协议错误]', error));
|
||||||
|
bridge.on('client-error', (error) => console.error('[Fcitx5 客户端错误]', error));
|
||||||
|
bridge.on('server-error', (error) => console.error('[Fcitx5 Socket 错误]', error));
|
||||||
|
backends.on('status-changed', () => { tray.refresh(); settingsWindow.publish(); });
|
||||||
|
backends.on('active-changed', () => { tray.refresh(); settingsWindow.publish(); });
|
||||||
|
sessions.on('state-changed', () => settingsWindow.publish());
|
||||||
|
settings.on('changed', () => settingsWindow.publish());
|
||||||
|
|
||||||
|
await bridge.start();
|
||||||
|
bridge.configure(settings.get().shortcut);
|
||||||
|
await backends.ensure(settings.get().backendId);
|
||||||
|
tray.refresh();
|
||||||
|
|
||||||
|
app.on('second-instance', () => settingsWindow.show());
|
||||||
|
app.on('before-quit', () => { (app as typeof app & { isQuitting?: boolean }).isQuitting = true; });
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
tray.destroy();
|
||||||
|
void bridge.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Services {
|
||||||
|
settings: SettingsStore;
|
||||||
|
autoStart: AutoStartService;
|
||||||
|
bridge: FcitxBridge;
|
||||||
|
backends: BackendManager;
|
||||||
|
overlay: OverlayController;
|
||||||
|
sessions: SessionController;
|
||||||
|
settingsWindow: SettingsWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIpc(services: Services): void {
|
||||||
|
const { settings, autoStart, bridge, backends, overlay, sessions, settingsWindow } = services;
|
||||||
|
const settingsSender = (senderId: number): boolean =>
|
||||||
|
Boolean(settingsWindow.window && !settingsWindow.window.isDestroyed() && settingsWindow.window.webContents.id === senderId);
|
||||||
|
const overlaySender = (senderId: number): boolean =>
|
||||||
|
Boolean(overlay.window && !overlay.window.isDestroyed() && overlay.window.webContents.id === senderId);
|
||||||
|
|
||||||
|
ipcMain.handle('settings:get-state', (event) => {
|
||||||
|
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的设置请求');
|
||||||
|
return settingsWindow.state();
|
||||||
|
});
|
||||||
|
ipcMain.handle('settings:set-backend', async (event, id: unknown) => {
|
||||||
|
if (!settingsSender(event.sender.id) || typeof id !== 'string') throw new Error('无效的后端设置');
|
||||||
|
if (sessions.state !== 'idle') throw new Error('请先结束当前语音输入');
|
||||||
|
await backends.select(id);
|
||||||
|
return settingsWindow.state();
|
||||||
|
});
|
||||||
|
ipcMain.handle('settings:open-debug', async (event) => {
|
||||||
|
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的窗口请求');
|
||||||
|
await backends.showDebug();
|
||||||
|
});
|
||||||
|
ipcMain.handle('settings:set-position', (event, point: Point, displayId: string) => {
|
||||||
|
if (!settingsSender(event.sender.id) || !isPoint(point)) throw new Error('无效的悬浮球坐标');
|
||||||
|
overlay.applyPosition(point, displayId);
|
||||||
|
return settingsWindow.state();
|
||||||
|
});
|
||||||
|
ipcMain.handle('settings:set-preset', (event, preset: unknown, displayId: string) => {
|
||||||
|
if (!settingsSender(event.sender.id) || typeof preset !== 'string') throw new Error('无效的位置预设');
|
||||||
|
overlay.applyPreset(preset, displayId);
|
||||||
|
return settingsWindow.state();
|
||||||
|
});
|
||||||
|
ipcMain.handle('settings:preview-overlay', (event) => {
|
||||||
|
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的预览请求');
|
||||||
|
overlay.preview();
|
||||||
|
});
|
||||||
|
ipcMain.handle('settings:set-shortcut', (event, shortcut: unknown) => {
|
||||||
|
if (!settingsSender(event.sender.id) || typeof shortcut !== 'string' || !validShortcut(shortcut)) {
|
||||||
|
throw new Error('快捷键必须包含修饰键和一个主键');
|
||||||
|
}
|
||||||
|
settings.update({ shortcut });
|
||||||
|
bridge.configure(shortcut);
|
||||||
|
return settingsWindow.state();
|
||||||
|
});
|
||||||
|
ipcMain.handle('settings:set-auto-start', (event, enabled: unknown) => {
|
||||||
|
if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的自启动设置');
|
||||||
|
autoStart.apply(enabled);
|
||||||
|
settings.update({ autoStart: enabled });
|
||||||
|
return settingsWindow.state();
|
||||||
|
});
|
||||||
|
ipcMain.handle('settings:set-overlay-persistent', (event, enabled: unknown) => {
|
||||||
|
if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的悬浮球设置');
|
||||||
|
settings.update({ overlayPersistent: enabled });
|
||||||
|
overlay.reconcilePersistent();
|
||||||
|
return settingsWindow.state();
|
||||||
|
});
|
||||||
|
ipcMain.handle('settings:open-repository', async (event) => {
|
||||||
|
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的外部链接请求');
|
||||||
|
await settingsWindow.openRepository();
|
||||||
|
});
|
||||||
|
ipcMain.on('overlay:toggle-recording', (event) => {
|
||||||
|
if (overlaySender(event.sender.id)) sessions.requestStartFromOverlay();
|
||||||
|
});
|
||||||
|
ipcMain.on('backend-status', (event, payload: BackendStatusPayload) => backends.handleStatus(event, payload));
|
||||||
|
ipcMain.on('backend-transcript', (event, payload: { backend?: string; text?: string }) => {
|
||||||
|
if (typeof payload?.backend === 'string' && typeof payload.text === 'string' && backends.senderIsActive(event, payload.backend)) {
|
||||||
|
sessions.update(payload.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.on('backend-session-ended', (event, payload: { backend?: string; text?: string }) => {
|
||||||
|
if (typeof payload?.backend === 'string' && typeof payload.text === 'string' && backends.senderIsActive(event, payload.backend)) {
|
||||||
|
sessions.finish(payload.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.on('backend-start-error', (event, payload: { backend?: string; message?: string }) => {
|
||||||
|
if (typeof payload?.backend === 'string' && backends.senderIsActive(event, payload.backend)) {
|
||||||
|
sessions.backendError(payload.message || '后端启动失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPoint(value: unknown): value is Point {
|
||||||
|
return typeof value === 'object' && value !== null &&
|
||||||
|
Number.isFinite((value as Point).x) && Number.isFinite((value as Point).y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validShortcut(value: string): boolean {
|
||||||
|
const parts = value.split('+').filter(Boolean);
|
||||||
|
return parts.length >= 2 && parts.some((part) => ['Control', 'Alt', 'Shift', 'Super'].includes(part)) &&
|
||||||
|
!['Control', 'Alt', 'Shift', 'Super'].includes(parts.at(-1)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => { /* 托盘进程继续常驻。 */ });
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { BrowserWindow, screen } from 'electron';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { Point, SessionState } from '../shared/contracts';
|
||||||
|
import { clampPosition, presetPosition } from './overlay-position';
|
||||||
|
import type { SettingsStore } from './settings-store';
|
||||||
|
|
||||||
|
const SIZE = { width: 88, height: 88 };
|
||||||
|
|
||||||
|
export class OverlayController {
|
||||||
|
window: BrowserWindow | null = null;
|
||||||
|
private hideTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly settings: SettingsStore) {}
|
||||||
|
|
||||||
|
async create(): Promise<void> {
|
||||||
|
this.window = new BrowserWindow({
|
||||||
|
...SIZE,
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
resizable: false,
|
||||||
|
movable: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
focusable: false,
|
||||||
|
skipTaskbar: true,
|
||||||
|
hasShadow: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '..', 'preload', 'overlay.js'),
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
backgroundThrottling: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.window.setAlwaysOnTop(true, 'floating');
|
||||||
|
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'overlay.html'));
|
||||||
|
this.position();
|
||||||
|
this.reconcilePersistent();
|
||||||
|
}
|
||||||
|
|
||||||
|
position(point = this.settings.get().overlayPosition): Point {
|
||||||
|
const initial = point ?? presetPosition(screen.getPrimaryDisplay().workArea, SIZE, 'bottom-center');
|
||||||
|
const display = screen.getDisplayNearestPoint(initial);
|
||||||
|
const result = clampPosition(initial, display.workArea, SIZE);
|
||||||
|
this.window?.setPosition(result.x, result.y, false);
|
||||||
|
if (!point) this.settings.update({ overlayPosition: result });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPosition(point: Point, displayId?: string): Point {
|
||||||
|
const display = screen.getAllDisplays().find((item) => String(item.id) === String(displayId)) ??
|
||||||
|
screen.getDisplayNearestPoint(point);
|
||||||
|
const result = clampPosition(point, display.workArea, SIZE);
|
||||||
|
this.settings.update({ overlayPosition: result });
|
||||||
|
this.window?.setPosition(result.x, result.y, false);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(preset: string, displayId?: string): Point {
|
||||||
|
const display = screen.getAllDisplays().find((item) => String(item.id) === String(displayId)) ??
|
||||||
|
screen.getPrimaryDisplay();
|
||||||
|
return this.applyPosition(presetPosition(display.workArea, SIZE, preset), String(display.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
showState(state: SessionState | 'done' | 'error'): void {
|
||||||
|
if (!this.window || this.window.isDestroyed()) return;
|
||||||
|
this.clearHideTimer();
|
||||||
|
this.window.webContents.send('overlay-state', { state });
|
||||||
|
if (!this.window.isVisible()) this.window.showInactive();
|
||||||
|
}
|
||||||
|
|
||||||
|
hideLater(delay = 900): void {
|
||||||
|
this.clearHideTimer();
|
||||||
|
if (this.settings.get().overlayPersistent) return;
|
||||||
|
this.hideTimer = setTimeout(() => this.window?.hide(), delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
preview(): void {
|
||||||
|
this.position();
|
||||||
|
this.showState('recording');
|
||||||
|
this.hideLater(1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcilePersistent(): void {
|
||||||
|
if (!this.window || this.window.isDestroyed()) return;
|
||||||
|
if (this.settings.get().overlayPersistent) {
|
||||||
|
this.showState('idle');
|
||||||
|
} else {
|
||||||
|
this.window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearHideTimer(): void {
|
||||||
|
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||||||
|
this.hideTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Rectangle } from 'electron';
|
||||||
|
import type { Point } from '../shared/contracts';
|
||||||
|
|
||||||
|
export const PRESETS = new Set([
|
||||||
|
'top-left', 'top-center', 'top-right',
|
||||||
|
'middle-left', 'middle-center', 'middle-right',
|
||||||
|
'bottom-left', 'bottom-center', 'bottom-right'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function presetPosition(workArea: Rectangle, size: { width: number; height: number }, preset: string, margin = 32): Point {
|
||||||
|
if (!PRESETS.has(preset)) throw new Error('未知的悬浮球位置预设');
|
||||||
|
const [vertical, horizontal] = preset.split('-');
|
||||||
|
const x = horizontal === 'left' ? workArea.x + margin
|
||||||
|
: horizontal === 'right' ? workArea.x + workArea.width - size.width - margin
|
||||||
|
: workArea.x + (workArea.width - size.width) / 2;
|
||||||
|
const y = vertical === 'top' ? workArea.y + margin
|
||||||
|
: vertical === 'bottom' ? workArea.y + workArea.height - size.height - margin
|
||||||
|
: workArea.y + (workArea.height - size.height) / 2;
|
||||||
|
return { x: Math.round(x), y: Math.round(y) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampPosition(position: Point, workArea: Rectangle, size: { width: number; height: number }): Point {
|
||||||
|
const maxX = workArea.x + Math.max(0, workArea.width - size.width);
|
||||||
|
const maxY = workArea.y + Math.max(0, workArea.height - size.height);
|
||||||
|
return {
|
||||||
|
x: Math.round(Math.min(Math.max(position.x, workArea.x), maxX)),
|
||||||
|
y: Math.round(Math.min(Math.max(position.y, workArea.y), maxY))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { app, BrowserWindow, screen, shell } from 'electron';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { SettingsViewState } from '../shared/contracts';
|
||||||
|
import type { BackendManager } from './backend-manager';
|
||||||
|
import type { OverlayController } from './overlay-controller';
|
||||||
|
import type { SessionController } from './session-controller';
|
||||||
|
import type { SettingsStore } from './settings-store';
|
||||||
|
|
||||||
|
export class SettingsWindow {
|
||||||
|
window: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly settings: SettingsStore,
|
||||||
|
private readonly backends: BackendManager,
|
||||||
|
private readonly overlay: OverlayController,
|
||||||
|
private readonly sessions: SessionController,
|
||||||
|
private readonly repositoryUrl: string | null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(): Promise<void> {
|
||||||
|
this.window = new BrowserWindow({
|
||||||
|
width: 720,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 640,
|
||||||
|
minHeight: 680,
|
||||||
|
show: false,
|
||||||
|
title: 'MeloASR 设置',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
icon: path.join(app.getAppPath(), 'dist', 'assets', 'logo.png'),
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '..', 'preload', 'settings.js'),
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.window.on('close', (event) => {
|
||||||
|
if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.window?.hide();
|
||||||
|
});
|
||||||
|
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
show(): void {
|
||||||
|
this.window?.show();
|
||||||
|
this.window?.focus();
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(): void {
|
||||||
|
if (!this.window || this.window.isDestroyed()) return;
|
||||||
|
this.window.webContents.send('settings:state', this.state());
|
||||||
|
}
|
||||||
|
|
||||||
|
state(): SettingsViewState {
|
||||||
|
const current = this.settings.get();
|
||||||
|
const position = this.overlay.position(current.overlayPosition);
|
||||||
|
return {
|
||||||
|
backendId: current.backendId,
|
||||||
|
backends: this.backends.definitions().map(({ id, label }) => ({ id, label, status: this.backends.status(id) })),
|
||||||
|
sessionState: this.sessions.state,
|
||||||
|
shortcut: current.shortcut,
|
||||||
|
autoStart: current.autoStart,
|
||||||
|
overlayPersistent: current.overlayPersistent,
|
||||||
|
overlayPosition: position,
|
||||||
|
displays: screen.getAllDisplays().map((display, index) => ({
|
||||||
|
id: String(display.id),
|
||||||
|
label: `屏幕 ${index + 1}(${display.workArea.width} × ${display.workArea.height})`,
|
||||||
|
workArea: display.workArea
|
||||||
|
})),
|
||||||
|
version: app.getVersion(),
|
||||||
|
repositoryUrl: this.repositoryUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async openRepository(): Promise<void> {
|
||||||
|
if (!this.repositoryUrl) throw new Error('尚未配置项目仓库地址');
|
||||||
|
await shell.openExternal(this.repositoryUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Menu, Tray, nativeImage } from 'electron';
|
||||||
|
import type { BackendManager } from './backend-manager';
|
||||||
|
import type { SettingsWindow } from './settings-window';
|
||||||
|
|
||||||
|
type TrayState = 'loading' | 'ready' | 'login-required' | 'error';
|
||||||
|
|
||||||
|
const COLORS: Record<TrayState, string> = {
|
||||||
|
loading: '#e6a23c',
|
||||||
|
ready: '#22b573',
|
||||||
|
'login-required': '#ef6c57',
|
||||||
|
error: '#d9465f'
|
||||||
|
};
|
||||||
|
|
||||||
|
function trayImage(state: TrayState): Electron.NativeImage {
|
||||||
|
const color = COLORS[state];
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<circle cx="16" cy="16" r="14" fill="${color}"/>
|
||||||
|
<path d="M8 19v-6h3v6zm5 4V9h3v14zm5-2V11h3v10zm5-3v-4h3v4z" fill="white"/>
|
||||||
|
</svg>`;
|
||||||
|
return nativeImage.createFromDataURL(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`).resize({ width: 22, height: 22 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TrayController {
|
||||||
|
private tray: Tray | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly backends: BackendManager,
|
||||||
|
private readonly settingsWindow: SettingsWindow,
|
||||||
|
private readonly onRestart: () => void,
|
||||||
|
private readonly onQuit: () => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
this.tray = new Tray(trayImage('loading'));
|
||||||
|
this.tray.setToolTip('MeloASR · 正在加载');
|
||||||
|
this.tray.on('click', () => this.settingsWindow.show());
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
if (!this.tray) return;
|
||||||
|
const status = this.backends.status(this.backends.activeDefinition().id);
|
||||||
|
const state: TrayState = status.ready ? 'ready'
|
||||||
|
: status.login === 'logged-out' ? 'login-required'
|
||||||
|
: status.detail.startsWith('加载失败') ? 'error' : 'loading';
|
||||||
|
this.tray.setImage(trayImage(state));
|
||||||
|
this.tray.setToolTip(`MeloASR · ${status.detail}`);
|
||||||
|
this.tray.setContextMenu(Menu.buildFromTemplate([
|
||||||
|
{ label: '设置', click: () => this.settingsWindow.show() },
|
||||||
|
{ label: '打开登录/调试页面', click: () => void this.backends.showDebug() },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: '重启', click: this.onRestart },
|
||||||
|
{ label: '退出', click: this.onQuit }
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.tray?.destroy();
|
||||||
|
this.tray = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('meloOverlay', {
|
||||||
|
toggleRecording: (): void => ipcRenderer.send('overlay:toggle-recording'),
|
||||||
|
onState: (listener: (state: string) => void): void => {
|
||||||
|
ipcRenderer.on('overlay-state', (_event, payload: { state: string }) => listener(payload.state));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
import type { Point, SettingsViewState } from '../shared/contracts';
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('meloSettings', {
|
||||||
|
getState: (): Promise<SettingsViewState> => ipcRenderer.invoke('settings:get-state'),
|
||||||
|
setBackend: (id: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-backend', id),
|
||||||
|
openDebug: (): Promise<void> => ipcRenderer.invoke('settings:open-debug'),
|
||||||
|
setPosition: (position: Point, displayId: string): Promise<SettingsViewState> =>
|
||||||
|
ipcRenderer.invoke('settings:set-position', position, displayId),
|
||||||
|
setPreset: (preset: string, displayId: string): Promise<SettingsViewState> =>
|
||||||
|
ipcRenderer.invoke('settings:set-preset', preset, displayId),
|
||||||
|
previewOverlay: (): Promise<void> => ipcRenderer.invoke('settings:preview-overlay'),
|
||||||
|
setShortcut: (shortcut: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-shortcut', shortcut),
|
||||||
|
setAutoStart: (enabled: boolean): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-auto-start', enabled),
|
||||||
|
setOverlayPersistent: (enabled: boolean): Promise<SettingsViewState> =>
|
||||||
|
ipcRenderer.invoke('settings:set-overlay-persistent', enabled),
|
||||||
|
openRepository: (): Promise<void> => ipcRenderer.invoke('settings:open-repository'),
|
||||||
|
onState: (listener: (state: SettingsViewState) => void): void => {
|
||||||
|
ipcRenderer.on('settings:state', (_event, state: SettingsViewState) => listener(state));
|
||||||
|
}
|
||||||
|
});
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Point, SettingsViewState } from '../shared/contracts';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
meloSettings: {
|
||||||
|
getState(): Promise<SettingsViewState>;
|
||||||
|
setBackend(id: string): Promise<SettingsViewState>;
|
||||||
|
openDebug(): Promise<void>;
|
||||||
|
setPosition(position: Point, displayId: string): Promise<SettingsViewState>;
|
||||||
|
setPreset(preset: string, displayId: string): Promise<SettingsViewState>;
|
||||||
|
previewOverlay(): Promise<void>;
|
||||||
|
setShortcut(shortcut: string): Promise<SettingsViewState>;
|
||||||
|
setAutoStart(enabled: boolean): Promise<SettingsViewState>;
|
||||||
|
setOverlayPersistent(enabled: boolean): Promise<SettingsViewState>;
|
||||||
|
openRepository(): Promise<void>;
|
||||||
|
onState(listener: (state: SettingsViewState) => void): void;
|
||||||
|
};
|
||||||
|
meloOverlay: {
|
||||||
|
toggleRecording(): void;
|
||||||
|
onState(listener: (state: string) => void): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
|
||||||
|
body { display: grid; place-items: center; }
|
||||||
|
.voice-overlay { position: relative; display: grid; place-items: center; width: 68px; height: 68px; padding: 0; border: 0; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #71aaff, #2568de 70%); box-shadow: 0 8px 24px rgba(23,79,177,.35); cursor: pointer; }
|
||||||
|
.ring { position: absolute; inset: 0; border: 2px solid rgba(69,136,245,.45); border-radius: 50%; opacity: 0; }
|
||||||
|
.voice-overlay[data-state="recording"] .ring { animation: ripple 1.7s ease-out infinite; }
|
||||||
|
.voice-overlay[data-state="recording"] .ring.two { animation-delay: .55s; }
|
||||||
|
.bars { display: flex; align-items: center; gap: 3px; height: 24px; }
|
||||||
|
.bars i { display: block; width: 4px; height: 10px; border-radius: 3px; background: white; }
|
||||||
|
.voice-overlay[data-state="recording"] .bars i { animation: wave .75s ease-in-out infinite alternate; }
|
||||||
|
.bars i:nth-child(2) { height: 20px; animation-delay: .12s !important; }
|
||||||
|
.bars i:nth-child(3) { height: 15px; animation-delay: .24s !important; }
|
||||||
|
.bars i:nth-child(4) { animation-delay: .36s !important; }
|
||||||
|
.voice-overlay[data-state="stopping"] { background: radial-gradient(circle at 35% 28%, #ffd087, #e68a22 70%); }
|
||||||
|
.voice-overlay[data-state="done"] { background: radial-gradient(circle at 35% 28%, #83ebbd, #22aa72 70%); }
|
||||||
|
.voice-overlay[data-state="error"] { background: radial-gradient(circle at 35% 28%, #ff9b9b, #dc3f52 70%); }
|
||||||
|
@keyframes ripple { from { transform: scale(.9); opacity: .7; } to { transform: scale(1.28); opacity: 0; } }
|
||||||
|
@keyframes wave { to { transform: scaleY(.45); } }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="overlay.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="voice-overlay" data-state="idle" aria-label="开始或停止语音输入">
|
||||||
|
<span class="ring one"></span><span class="ring two"></span>
|
||||||
|
<span class="bars"><i></i><i></i><i></i><i></i></span>
|
||||||
|
</button>
|
||||||
|
<script src="overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const root = document.querySelector<HTMLElement>('.voice-overlay')!;
|
||||||
|
|
||||||
|
root.addEventListener('click', () => window.meloOverlay.toggleRecording());
|
||||||
|
root.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') window.meloOverlay.toggleRecording();
|
||||||
|
});
|
||||||
|
window.meloOverlay.onState((state) => { root.dataset.state = state; });
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
:root { color-scheme: light; font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif; color: #182033; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: #f4f6fa; }
|
||||||
|
header { display: flex; align-items: center; gap: 12px; padding: 22px 28px 12px; }
|
||||||
|
header img { width: 44px; height: 44px; border-radius: 11px; }
|
||||||
|
header h1 { margin: 0; font-size: 22px; }
|
||||||
|
header span { color: #788398; }
|
||||||
|
main { display: grid; gap: 14px; padding: 0 28px 30px; }
|
||||||
|
section { padding: 18px; background: white; border: 1px solid #e0e5ee; border-radius: 13px; box-shadow: 0 5px 20px rgba(33,48,77,.05); }
|
||||||
|
h2 { margin: 0 0 14px; font-size: 16px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 12px; min-height: 40px; }
|
||||||
|
.row > label, .row > span:first-child { min-width: 80px; color: #5f6b80; }
|
||||||
|
select, input, button { height: 38px; border-radius: 8px; font: inherit; }
|
||||||
|
select, input { padding: 0 10px; border: 1px solid #cad3e1; background: white; }
|
||||||
|
select { flex: 1; }
|
||||||
|
button { padding: 0 14px; border: 0; background: #e9eef7; color: #26334a; cursor: pointer; font-weight: 600; }
|
||||||
|
button.primary { background: #286df0; color: white; }
|
||||||
|
button:disabled { opacity: .55; cursor: default; }
|
||||||
|
.status { padding: 4px 9px; border-radius: 99px; font-size: 12px; background: #edf0f5; }
|
||||||
|
.status[data-state="ready"] { color: #087b4d; background: #ddf6eb; }
|
||||||
|
.status[data-state="warning"] { color: #a84332; background: #fde8e4; }
|
||||||
|
.warning { margin: 8px 0 12px; padding: 9px 11px; border-radius: 8px; color: #a84332; background: #fff0ed; }
|
||||||
|
.switch { display: flex; align-items: center; gap: 10px; min-height: 40px; }
|
||||||
|
.switch input { width: 17px; height: 17px; }
|
||||||
|
.screen { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 12px 0; padding: 15px; aspect-ratio: 16/5; border: 2px solid #b9c4d6; border-radius: 10px; background: #f7f9fc; }
|
||||||
|
.screen button { align-self: center; justify-self: center; width: 24px; height: 24px; padding: 0; border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; }
|
||||||
|
.coordinates { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.coordinates label { display: grid; grid-template-columns: 18px 1fr; align-items: center; color: #5f6b80; }
|
||||||
|
.coordinates input { width: 100%; }
|
||||||
|
.buttons { display: flex; gap: 10px; margin-top: 12px; }
|
||||||
|
.hint { margin-top: 7px; color: #7b8698; font-size: 12px; }
|
||||||
|
.shortcut { margin-left: auto; min-width: 180px; }
|
||||||
|
.link { margin-left: auto; color: #286df0; background: transparent; }
|
||||||
|
#message { min-height: 21px; color: #55708f; }
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>MeloASR 设置</title>
|
||||||
|
<link rel="stylesheet" href="settings.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><img src="../assets/logo.png" alt=""><div><h1>MeloASR</h1><span>设置</span></div></header>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<h2>语音后端</h2>
|
||||||
|
<div class="row"><label for="backend">后端</label><select id="backend"></select><span id="backend-status" class="status"></span></div>
|
||||||
|
<div id="login-warning" class="warning" hidden>当前后端尚未登录。</div>
|
||||||
|
<button id="open-debug">打开登录/调试页面</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>悬浮球</h2>
|
||||||
|
<label class="switch"><input id="overlay-persistent" type="checkbox"><span>始终显示悬浮球</span></label>
|
||||||
|
<div class="row"><label for="display">屏幕</label><select id="display"></select></div>
|
||||||
|
<div class="screen" aria-label="九宫格位置预设">
|
||||||
|
<button data-preset="top-left" title="左上"></button><button data-preset="top-center" title="顶部居中"></button><button data-preset="top-right" title="右上"></button>
|
||||||
|
<button data-preset="middle-left" title="左侧居中"></button><button data-preset="middle-center" title="屏幕中央"></button><button data-preset="middle-right" title="右侧居中"></button>
|
||||||
|
<button data-preset="bottom-left" title="左下"></button><button data-preset="bottom-center" title="底部居中"></button><button data-preset="bottom-right" title="右下"></button>
|
||||||
|
</div>
|
||||||
|
<div class="coordinates">
|
||||||
|
<label for="position-x">X <input id="position-x" type="number" step="1"></label>
|
||||||
|
<label for="position-y">Y <input id="position-y" type="number" step="1"></label>
|
||||||
|
</div>
|
||||||
|
<div class="hint">方向键微调 1 像素,Shift + 方向键微调 10 像素</div>
|
||||||
|
<div class="buttons"><button id="apply-position" class="primary">应用坐标</button><button id="preview-overlay">预览</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>常规</h2>
|
||||||
|
<div class="row"><span>按住说话快捷键</span><button id="shortcut" class="shortcut"></button></div>
|
||||||
|
<label class="switch"><input id="auto-start" type="checkbox"><span>开机自动启动</span></label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>关于</h2>
|
||||||
|
<div class="row"><span>版本</span><span id="version"></span></div>
|
||||||
|
<div class="row"><span>项目仓库</span><button id="repository" class="link"></button></div>
|
||||||
|
</section>
|
||||||
|
<div id="message" role="status"></div>
|
||||||
|
</main>
|
||||||
|
<script src="settings.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
interface BackendRuntimeStatus {
|
||||||
|
ready: boolean;
|
||||||
|
login: 'unknown' | 'logged-in' | 'logged-out';
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsViewState {
|
||||||
|
backendId: string;
|
||||||
|
backends: Array<{ id: string; label: string; status: BackendRuntimeStatus }>;
|
||||||
|
sessionState: 'idle' | 'starting' | 'recording' | 'stopping';
|
||||||
|
shortcut: string;
|
||||||
|
autoStart: boolean;
|
||||||
|
overlayPersistent: boolean;
|
||||||
|
overlayPosition: { x: number; y: number };
|
||||||
|
displays: Array<{ id: string; label: string; workArea: { x: number; y: number; width: number; height: number } }>;
|
||||||
|
version: string;
|
||||||
|
repositoryUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = document.querySelector<HTMLSelectElement>('#backend')!;
|
||||||
|
const backendStatus = document.querySelector<HTMLElement>('#backend-status')!;
|
||||||
|
const loginWarning = document.querySelector<HTMLElement>('#login-warning')!;
|
||||||
|
const display = document.querySelector<HTMLSelectElement>('#display')!;
|
||||||
|
const x = document.querySelector<HTMLInputElement>('#position-x')!;
|
||||||
|
const y = document.querySelector<HTMLInputElement>('#position-y')!;
|
||||||
|
const shortcut = document.querySelector<HTMLButtonElement>('#shortcut')!;
|
||||||
|
const autoStart = document.querySelector<HTMLInputElement>('#auto-start')!;
|
||||||
|
const overlayPersistent = document.querySelector<HTMLInputElement>('#overlay-persistent')!;
|
||||||
|
const version = document.querySelector<HTMLElement>('#version')!;
|
||||||
|
const repository = document.querySelector<HTMLButtonElement>('#repository')!;
|
||||||
|
const message = document.querySelector<HTMLElement>('#message')!;
|
||||||
|
let state: SettingsViewState;
|
||||||
|
let capturingShortcut = false;
|
||||||
|
|
||||||
|
function statusText(status: BackendRuntimeStatus): string {
|
||||||
|
if (status.ready) return '已就绪';
|
||||||
|
if (status.login === 'logged-out') return '需要登录';
|
||||||
|
return status.detail || '正在加载';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(next: SettingsViewState): void {
|
||||||
|
state = next;
|
||||||
|
backend.replaceChildren(...next.backends.map((item) => new Option(item.label, item.id)));
|
||||||
|
backend.value = next.backendId;
|
||||||
|
const active = next.backends.find((item) => item.id === next.backendId)!;
|
||||||
|
backendStatus.textContent = statusText(active.status);
|
||||||
|
backendStatus.dataset.state = active.status.ready ? 'ready'
|
||||||
|
: active.status.login === 'logged-out' ? 'warning' : 'loading';
|
||||||
|
loginWarning.hidden = active.status.login !== 'logged-out';
|
||||||
|
|
||||||
|
const previousDisplay = display.value;
|
||||||
|
display.replaceChildren(...next.displays.map((item) => new Option(item.label, item.id)));
|
||||||
|
const containing = next.displays.find((item) => {
|
||||||
|
const area = item.workArea;
|
||||||
|
return next.overlayPosition.x >= area.x && next.overlayPosition.x < area.x + area.width &&
|
||||||
|
next.overlayPosition.y >= area.y && next.overlayPosition.y < area.y + area.height;
|
||||||
|
});
|
||||||
|
display.value = next.displays.some((item) => item.id === previousDisplay)
|
||||||
|
? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? '');
|
||||||
|
x.value = String(next.overlayPosition.x);
|
||||||
|
y.value = String(next.overlayPosition.y);
|
||||||
|
shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut);
|
||||||
|
autoStart.checked = next.autoStart;
|
||||||
|
overlayPersistent.checked = next.overlayPersistent;
|
||||||
|
version.textContent = next.version;
|
||||||
|
repository.textContent = next.repositoryUrl ?? '尚未配置';
|
||||||
|
repository.disabled = !next.repositoryUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayShortcut(value: string): string {
|
||||||
|
return value.replaceAll('Control', 'Ctrl').replaceAll('Super', 'Meta').replace(/\+space$/i, '+Space');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortcut(event: KeyboardEvent): string | null {
|
||||||
|
const modifiers = [
|
||||||
|
event.ctrlKey ? 'Control' : '',
|
||||||
|
event.altKey ? 'Alt' : '',
|
||||||
|
event.shiftKey ? 'Shift' : '',
|
||||||
|
event.metaKey ? 'Super' : ''
|
||||||
|
].filter(Boolean);
|
||||||
|
if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key) || modifiers.length === 0) return null;
|
||||||
|
const key = event.code === 'Space' ? 'space'
|
||||||
|
: event.code.startsWith('Key') ? event.code.slice(3).toLowerCase()
|
||||||
|
: event.code.startsWith('Digit') ? event.code.slice(5)
|
||||||
|
: event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||||
|
return [...modifiers, key].join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply(action: () => Promise<SettingsViewState>, confirmation = '已保存'): Promise<void> {
|
||||||
|
try {
|
||||||
|
render(await action());
|
||||||
|
message.textContent = confirmation;
|
||||||
|
} catch (error) {
|
||||||
|
message.textContent = (error as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPosition(): Promise<void> {
|
||||||
|
await apply(() => window.meloSettings.setPosition({ x: Number(x.value), y: Number(y.value) }, display.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value)));
|
||||||
|
document.querySelector('#open-debug')!.addEventListener('click', () => void window.meloSettings.openDebug());
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => void apply(
|
||||||
|
() => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存'
|
||||||
|
));
|
||||||
|
});
|
||||||
|
document.querySelector('#apply-position')!.addEventListener('click', () => void applyPosition());
|
||||||
|
document.querySelector('#preview-overlay')!.addEventListener('click', () => void window.meloSettings.previewOverlay());
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (capturingShortcut) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
capturingShortcut = false;
|
||||||
|
render(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = formatShortcut(event);
|
||||||
|
if (!value) return;
|
||||||
|
capturingShortcut = false;
|
||||||
|
void apply(() => window.meloSettings.setShortcut(value), '快捷键已保存');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
||||||
|
if ((event.target as HTMLElement).matches('select, button, input[type="checkbox"]')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const step = event.shiftKey ? 10 : 1;
|
||||||
|
if (event.key === 'ArrowLeft') x.value = String(Number(x.value) - step);
|
||||||
|
if (event.key === 'ArrowRight') x.value = String(Number(x.value) + step);
|
||||||
|
if (event.key === 'ArrowUp') y.value = String(Number(y.value) - step);
|
||||||
|
if (event.key === 'ArrowDown') y.value = String(Number(y.value) + step);
|
||||||
|
void applyPosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
shortcut.addEventListener('click', () => {
|
||||||
|
capturingShortcut = true;
|
||||||
|
shortcut.textContent = '请按下新快捷键…';
|
||||||
|
shortcut.focus();
|
||||||
|
});
|
||||||
|
autoStart.addEventListener('change', () => void apply(() => window.meloSettings.setAutoStart(autoStart.checked)));
|
||||||
|
overlayPersistent.addEventListener('change', () => void apply(
|
||||||
|
() => window.meloSettings.setOverlayPersistent(overlayPersistent.checked)
|
||||||
|
));
|
||||||
|
repository.addEventListener('click', () => void window.meloSettings.openRepository());
|
||||||
|
window.meloSettings.onState(render);
|
||||||
|
void window.meloSettings.getState().then(render).catch((error: Error) => { message.textContent = error.message; });
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { clampPosition, presetPosition } from '../src/main/overlay-position';
|
||||||
|
|
||||||
|
const workArea = { x: 100, y: 50, width: 1200, height: 800 };
|
||||||
|
const size = { width: 88, height: 88 };
|
||||||
|
|
||||||
|
test('九宫格预设基于屏幕工作区计算', () => {
|
||||||
|
assert.deepEqual(presetPosition(workArea, size, 'top-left'), { x: 132, y: 82 });
|
||||||
|
assert.deepEqual(presetPosition(workArea, size, 'middle-center'), { x: 656, y: 406 });
|
||||||
|
assert.deepEqual(presetPosition(workArea, size, 'bottom-right'), { x: 1180, y: 730 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('精确坐标被限制在工作区内', () => {
|
||||||
|
assert.deepEqual(clampPosition({ x: -500, y: 2000 }, workArea, size), { x: 100, y: 762 });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user