Visitar URL original
feat: customize shortcuts and refine reader interactions · PDFMathTranslate/PDFMathReader@4b3deef · GitHub
Skip to content

Commit 4b3deef

Browse files
committed
feat: customize shortcuts and refine reader interactions
1 parent 9a8acaf commit 4b3deef

28 files changed

Lines changed: 1435 additions & 184 deletions

‎electron/main/ipc/preferences-ipc.mjs‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { nativeTheme, shell } from 'electron';
22
import { mkdir } from 'node:fs/promises';
3+
import {
4+
shortcutCatalog,
5+
effectiveShortcutBindings,
6+
validateShortcutBinding,
7+
} from '../../../shared/commands/shortcuts.mjs';
38

49
const WINDOW_LOCAL_PREFERENCES = [
510
'engine',
@@ -72,6 +77,52 @@ export function registerPreferencesIPC({
7277
rebuildMenu,
7378
updateMenu,
7479
}) {
80+
const shortcutSnapshot = () => {
81+
const overrides = preferences.load().shortcutBindings || {};
82+
return {
83+
catalog: shortcutCatalog(process.platform),
84+
bindings: effectiveShortcutBindings(process.platform, overrides),
85+
overrides,
86+
};
87+
};
88+
const syncShortcuts = () => {
89+
const snapshot = shortcutSnapshot();
90+
for (const [window, state] of registry.windows) {
91+
state.preferences.shortcutBindings = { ...snapshot.overrides };
92+
window.webContents.send('shortcuts:changed', snapshot);
93+
}
94+
rebuildMenu();
95+
return snapshot;
96+
};
97+
handle('shortcuts:load', (event) => {
98+
trustedWindow(event);
99+
return shortcutSnapshot();
100+
});
101+
handle('shortcuts:recording', (event, active) => {
102+
const target = trustedWindow(event);
103+
if (typeof active !== 'boolean') throw Error('Invalid shortcut recording state');
104+
registry.stateFor(target).shortcutRecording = active;
105+
target.webContents.setIgnoreMenuShortcuts(active);
106+
return active;
107+
});
108+
for (const action of ['save', 'reset'])
109+
handle(`shortcuts:${action}`, async (event, id, value) => {
110+
trustedWindow(event);
111+
const current = preferences.load().shortcutBindings || {};
112+
const next =
113+
action === 'reset' && id === undefined
114+
? {}
115+
: validateShortcutBinding(
116+
process.platform,
117+
current,
118+
id,
119+
action === 'reset' ? undefined : value,
120+
);
121+
const write = preferences.save({ shortcutBindings: next });
122+
const snapshot = syncShortcuts();
123+
await write;
124+
return snapshot;
125+
});
75126
for (const action of ['load', 'save'])
76127
handle(`preferences:${action}`, async (event, value) => {
77128
const target = trustedWindow(event);
@@ -80,6 +131,8 @@ export function registerPreferencesIPC({
80131
const previous = preferences.load();
81132
const write = preferences.save(value);
82133
const next = preferences.load();
134+
if (JSON.stringify(previous.shortcutBindings) !== JSON.stringify(next.shortcutBindings))
135+
syncShortcuts();
83136
if (previous.autoCheckUpdates !== next.autoCheckUpdates)
84137
appUpdates.setAutomatic(next.autoCheckUpdates);
85138
if (previous.cacheLimitMB !== next.cacheLimitMB) {

‎electron/main/menus/application-menu.mjs‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BrowserWindow as ElectronBrowserWindow, Menu as ElectronMenu, dialog } from 'electron';
22
import { createPDFApplicationService } from '../services/pdf-applications.mjs';
33
import { aboutPanelOptions } from '../services/about-panel.mjs';
4+
import { shortcutCatalog, effectiveShortcutBindings } from '../../../shared/commands/shortcuts.mjs';
45

56
export function createApplicationMenu({
67
app,
@@ -377,6 +378,8 @@ export function createApplicationMenu({
377378
submenu: [
378379
command('Previous Page', undefined, 'page-previous', 'go-previous'),
379380
command('Next Page', undefined, 'page-next', 'go-next'),
381+
command('First Page', undefined, 'page-first', 'go-first'),
382+
command('Last Page', undefined, 'page-last', 'go-last'),
380383
{ type: 'separator' },
381384
...Array.from({ length: 9 }, (_, i) =>
382385
command(
@@ -449,7 +452,40 @@ export function createApplicationMenu({
449452
...appItems.filter((item) => item.type !== 'separator' && !promotedIds.includes(item.id)),
450453
];
451454
}
452-
const localized = localize(platformTemplate);
455+
const bindings = effectiveShortcutBindings(
456+
platform,
457+
preferences?.load?.().shortcutBindings || {},
458+
);
459+
const catalog = shortcutCatalog(platform);
460+
const bind = (items) =>
461+
items.map((item) => {
462+
const entry = catalog.find((entry) => entry.menuId === item.id);
463+
// Display every binding, but route keyboard input through our shared
464+
// dispatcher so editable fields and shortcut recording retain focus.
465+
const binding = entry ? bindings[entry.id][0] : undefined;
466+
const accelerator = binding;
467+
return {
468+
...item,
469+
...(entry
470+
? {
471+
accelerator,
472+
registerAccelerator: false,
473+
...(item.role === 'togglefullscreen'
474+
? {
475+
role: undefined,
476+
label: 'Toggle Full Screen',
477+
click: (_item, target) => {
478+
const receiver = target || focusedWindow();
479+
receiver?.setFullScreen(!receiver.isFullScreen());
480+
},
481+
}
482+
: {}),
483+
}
484+
: {}),
485+
...(Array.isArray(item.submenu) ? { submenu: bind(item.submenu) } : {}),
486+
};
487+
});
488+
const localized = localize(bind(platformTemplate));
453489
const actions = new Map();
454490
const indexActions = (items) => {
455491
for (const item of items) {

‎electron/main/services/preferences.mjs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { cloneGlossaries, validGlossaries } from '../../../shared/translation/glossary.mjs';
2+
import { validShortcutOverrides } from '../../../shared/commands/shortcuts.mjs';
23
import {
34
cloneTranslationServiceHistory,
45
isValidTranslationServiceHistory,
@@ -30,6 +31,7 @@ const DEFAULT_PREFERENCES = Object.freeze({
3031
interactionMode: 'reading',
3132
restoreDocuments: true,
3233
formulaOcrEnabled: false,
34+
translationErrorDismissals: [],
3335
reduceResourceUsage: true,
3436
reduceBackgroundFrameRate: true,
3537
reuseTranslations: true,
@@ -64,6 +66,7 @@ const OPTIONAL_KEYS = Object.freeze([
6466
'translationServices',
6567
'translationServiceHistory',
6668
'glossaries',
69+
'shortcutBindings',
6770
]);
6871
const PREFERENCE_KEYS = Object.freeze([...KNOWN_KEYS, ...OPTIONAL_KEYS]);
6972
const KNOWN_KEY_SET = new Set(PREFERENCE_KEYS);
@@ -209,6 +212,8 @@ function clonePreferences(value) {
209212
...value,
210213
kernelAdvancedOptions: cloneKernelAdvancedOptions(value?.kernelAdvancedOptions),
211214
};
215+
if (Object.hasOwn(value || {}, 'shortcutBindings'))
216+
clone.shortcutBindings = { ...value.shortcutBindings };
212217
if (Object.prototype.hasOwnProperty.call(value || {}, 'translationServices'))
213218
clone.translationServices = cloneTranslationServices(value.translationServices);
214219
if (Object.hasOwn(value || {}, 'translationServiceHistory'))
@@ -221,6 +226,7 @@ function clonePreferences(value) {
221226
}
222227

223228
const VALIDATORS = {
229+
shortcutBindings: (value) => validShortcutOverrides(process.platform, value),
224230
autoCheckUpdates: (value) => typeof value === 'boolean',
225231
cacheLimitMB: (value) => value === null || [512, 1024, 2048, 5120, 10240].includes(value),
226232
documentOpenMode: (value) => ['translation', 'original', 'manual'].includes(value),
@@ -229,6 +235,8 @@ const VALIDATORS = {
229235
reduceBackgroundFrameRate: (value) => typeof value === 'boolean',
230236
reuseTranslations: (value) => typeof value === 'boolean',
231237
formulaOcrEnabled: (value) => typeof value === 'boolean',
238+
translationErrorDismissals: (value) =>
239+
Array.isArray(value) && value.every((id) => typeof id === 'string' && id.length <= 1024),
232240
emphasizeTopicSentences: (value) => typeof value === 'boolean',
233241
emphasizeInformation: (value) => typeof value === 'boolean',
234242
emphasizeResearchFindings: (value) => typeof value === 'boolean',

‎electron/main/windows/window-controller.mjs‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,17 @@ export function createWindowController({
284284
target.on(event, activityChanged);
285285
target.webContents.setZoomFactor(1);
286286
target.webContents.setVisualZoomLevelLimits(1, 1);
287+
const cancelShortcutRecording = () => {
288+
const state = registry.stateFor(target);
289+
if (state) state.shortcutRecording = false;
290+
if (!target.webContents.isDestroyed()) target.webContents.setIgnoreMenuShortcuts(false);
291+
};
292+
target.on('blur', cancelShortcutRecording);
293+
target.webContents.on('did-start-loading', cancelShortcutRecording);
294+
target.webContents.on('render-process-gone', cancelShortcutRecording);
287295
target.webContents.on('before-input-event', (event, input) => {
288296
const state = registry.stateFor(target);
297+
if (state?.shortcutRecording) return;
289298
if (
290299
state?.preferences?.interactionMode === 'reading' &&
291300
input.type === 'keyDown' &&
@@ -310,8 +319,13 @@ export function createWindowController({
310319
target.webContents.send('reader:action', 'copy-paragraph');
311320
return;
312321
}
313-
const action = shortcutAction(process.platform, input);
314-
if (action === 'page-previous' || action === 'page-next') return;
322+
const action = shortcutAction(
323+
process.platform,
324+
input,
325+
state?.preferences?.shortcutBindings || {},
326+
);
327+
// Unmodified browsing keys are handled in the renderer so text fields
328+
// retain their normal caret and scrolling behavior.
315329
if (action === 'new-window') {
316330
event.preventDefault();
317331
void createWindow().catch(handleBackendFailure);
@@ -327,6 +341,7 @@ export function createWindowController({
327341
target.close();
328342
return;
329343
}
344+
if (action && !input.meta && !input.control && !input.alt) return;
330345
if (action) {
331346
event.preventDefault();
332347
target.webContents.send('reader:action', action);
@@ -355,6 +370,13 @@ export function createWindowController({
355370
target.webContents.send('reader:action', 'search-selection', params.selectionText),
356371
},
357372
];
373+
if (process.platform === 'darwin') {
374+
template.push({
375+
label: 'AI Chat…',
376+
click: () =>
377+
target.webContents.send('reader:action', 'chat-selection', params.selectionText),
378+
});
379+
}
358380
if (
359381
process.platform === 'darwin' &&
360382
typeof target.webContents.showDefinitionForSelection === 'function'
@@ -545,6 +567,7 @@ export function createWindowController({
545567
'translation',
546568
'providers',
547569
'kernel',
570+
'shortcuts',
548571
'about',
549572
].includes(section)
550573
)

‎electron/preload.cjs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,21 @@ contextBridge.exposeInMainWorld(
120120
}),
121121
);
122122

123+
contextBridge.exposeInMainWorld(
124+
'previewShortcuts',
125+
Object.freeze({
126+
load: () => ipcRenderer.invoke('shortcuts:load'),
127+
save: (id, value) => ipcRenderer.invoke('shortcuts:save', id, value),
128+
reset: (id) => ipcRenderer.invoke('shortcuts:reset', id),
129+
recording: (active) => ipcRenderer.invoke('shortcuts:recording', active),
130+
onChange: (callback) => {
131+
const listener = (_event, value) => callback(value);
132+
ipcRenderer.on('shortcuts:changed', listener);
133+
return () => ipcRenderer.removeListener('shortcuts:changed', listener);
134+
},
135+
}),
136+
);
137+
123138
contextBridge.exposeInMainWorld(
124139
'previewWindow',
125140
Object.freeze({

‎server/kernels/translation-runner.mjs‎

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,38 @@ import { createHash } from 'node:crypto';
44
import { translationAdvancedArgs } from './kernel-options.mjs';
55
import { createTranslationCache } from '../translation/translation-cache.mjs';
66
import { buildKernelServiceConfig } from './kernel-services.mjs';
7+
import { redactDiagnosticText } from '../diagnostics/developer-diagnostics.mjs';
78
import {
89
isTranslationLanguageSupported,
910
translationLanguageCode,
1011
} from '../../shared/translation/languages.mjs';
1112

13+
const MAX_STDERR_TAIL_LENGTH = 4 * 1024;
14+
const USEFUL_ERROR_LINE =
15+
/(?:^|[\s:])(?:[A-Za-z_][\w.]*(?:Error|Exception|Failure|Interrupt)\b|(?:error|exception|failure|fatal|failed)\b)/i;
16+
17+
function appendTail(current, value, limit) {
18+
const next = current + value;
19+
return next.length > limit ? next.slice(-limit) : next;
20+
}
21+
22+
function finalStderrMessage(stderr) {
23+
const lines = String(stderr)
24+
.split(/\r?\n/)
25+
.map((line) => line.trim())
26+
.filter(Boolean);
27+
if (!lines.length) return '';
28+
const exception = [...lines]
29+
.reverse()
30+
.find(
31+
(line) =>
32+
USEFUL_ERROR_LINE.test(line) && !/^Traceback \(most recent call last\):$/i.test(line),
33+
);
34+
if (exception) return exception;
35+
const last = lines.at(-1);
36+
return /^Traceback \(most recent call last\):$/i.test(last) ? '' : last;
37+
}
38+
1239
let pdfLib;
1340

1441
function loadPdfLib() {
@@ -289,10 +316,15 @@ export function createTranslationRunner({
289316
secrets: [proxy?.token, ...(service?.secrets || [])],
290317
},
291318
);
319+
const secrets = [proxy?.token, ...(service?.secrets || [])];
320+
let stderrTail = '';
292321
child.stderr.on('data', (chunk) => {
293-
let message = String(chunk);
294-
for (const secret of [proxy.token, ...(service?.secrets || [])])
295-
if (secret) message = message.replaceAll(secret, '[redacted]');
322+
const text = String(chunk);
323+
const message = redactDiagnosticText(text, {
324+
secrets,
325+
maxLength: Math.max(text.length, 1),
326+
});
327+
stderrTail = appendTail(stderrTail, message, MAX_STDERR_TAIL_LENGTH);
296328
onDiagnostic?.(message);
297329
});
298330
const kill = () => {
@@ -301,7 +333,7 @@ export function createTranslationRunner({
301333
const timer = setTimeout(kill, 15 * 60 * 1000);
302334
signal?.addEventListener('abort', kill, { once: true });
303335
child.on('error', () => reject(Error('Kernel could not start')));
304-
child.on('close', (code) => {
336+
child.on('close', (code, exitSignal) => {
305337
clearTimeout(timer);
306338
signal?.removeEventListener('abort', kill);
307339
code === 0
@@ -310,7 +342,15 @@ export function createTranslationRunner({
310342
Error(
311343
signal?.aborted
312344
? 'Cancelled'
313-
: 'Kernel translation failed. Check its runtime assets and provider configuration.',
345+
: `Kernel translation failed: ${
346+
finalStderrMessage(
347+
redactDiagnosticText(stderrTail, {
348+
secrets,
349+
maxLength: MAX_STDERR_TAIL_LENGTH,
350+
}),
351+
) ||
352+
(exitSignal ? `signal ${exitSignal}` : `exit code ${code ?? 'unknown'}`)
353+
}`,
314354
),
315355
);
316356
});

0 commit comments

Comments
 (0)