* Ignore AltGr keystrokes in Ctrl+Alt keyboard shortcuts
Browsers report AltGr (right Alt on AZERTY/QWERTZ and most non-US
layouts, used to type @ # { } [ ] | \ and the euro sign) as
ctrlKey+altKey. The default keybinds map destructive actions to
Ctrl+Alt+<letter> (delete_session, new_session, incognito,
open_calendar), so a non-US user typing a special character could
silently fire them.
Guard the shortcut matcher, the editor keydown handler, and the rebind
capture with getModifierState('AltGraph'), which is true for AltGr but
false for a genuine left Ctrl+Alt. macOS is excluded: there the Option
key legitimately sets AltGraph and there is no AltGr/Ctrl+Alt collision
to guard against, so the guard would otherwise break Ctrl+Option /
Cmd+Option shortcuts (notably in Firefox).
The detection lives in one place — isAltGrEvent / IS_MAC in
static/js/platform.js — and all three call sites route through it, so the
guards can't drift apart.
The editor handler only skips the Ctrl+Alt chord block, so layout
shortcuts reachable via AltGr (e.g. [ ] brush size = AltGr+5/+8 on
AZERTY) keep working.
* Require Ctrl+Alt for the AltGr guard and consolidate keybind test marks
isAltGrEvent now also checks ctrlKey+altKey so it only suppresses the
"AltGr reported as Ctrl+Alt" collision; an event asserting AltGraph on
its own (a Linux ISO_Level3_Shift layout, a stray modifier) is left
alone. Pin it with test_isaltgr_false_when_altgraph_set_but_not_ctrl_alt.
Collapse the 12 per-test node skipif marks into one module-level
pytestmark, and note in platform.js why IS_MAC intentionally covers
iPad/iPhone and mirrors the isMac checks in calendar.js / sessions.js.
293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
// ============================================
|
|
// Keyboard Shortcuts — dynamic keybinds
|
|
// ============================================
|
|
|
|
import { IS_MAC, isAltGrEvent } from './platform.js';
|
|
|
|
const _defaultKeybinds = {
|
|
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
|
|
fav_session: 'ctrl+alt+f', delete_session: 'ctrl+alt+d',
|
|
cancel: 'escape', tts: 'alt+shift+t',
|
|
incognito: 'ctrl+alt+i', settings: 'ctrl+,', focus_input: 'ctrl+/',
|
|
// Open-tool shortcuts (Calendar bound by default; rest unbound).
|
|
open_calendar: 'ctrl+alt+c', open_compare: '', open_cookbook: '',
|
|
open_research: '', open_gallery: '', open_library: '', open_memory: '',
|
|
open_notes: '', open_tasks: '', open_theme: '',
|
|
};
|
|
|
|
export function _matchesCombo(e, combo, isMac = IS_MAC) {
|
|
if (!combo) return false;
|
|
// Drop AltGr keystrokes so typing characters on non-US layouts can't fire a
|
|
// Ctrl+Alt shortcut — e.g. the destructive delete_session. See platform.js.
|
|
if (isAltGrEvent(e, isMac)) return false;
|
|
const parts = combo.split('+');
|
|
const needCtrl = parts.includes('ctrl');
|
|
const needAlt = parts.includes('alt');
|
|
const needShift = parts.includes('shift');
|
|
const key = parts.filter(p => p !== 'ctrl' && p !== 'alt' && p !== 'shift')[0] || '';
|
|
if (needCtrl !== (e.ctrlKey || e.metaKey)) return false;
|
|
if (needAlt !== e.altKey) return false;
|
|
if (needShift !== e.shiftKey) return false;
|
|
return e.key.toLowerCase() === key;
|
|
}
|
|
|
|
/**
|
|
* Initialize keyboard shortcuts.
|
|
* @param {Object} modules - References to app modules and helpers
|
|
* @param {Function} modules.el - Element lookup helper (uiModule.el)
|
|
* @param {Object} modules.Storage - Storage module
|
|
* @param {Object} modules.sessionModule
|
|
* @param {Object} modules.uiModule
|
|
* @param {Object} modules.chatModule
|
|
* @param {Object} modules.adminModule
|
|
* @param {Object} modules.settingsModule
|
|
* @param {Object} modules.searchChatModule
|
|
* @param {Function} modules._closeCompareIfActive
|
|
* @param {Function} modules._deactivateIncognito
|
|
* @param {string} modules.API_BASE
|
|
*/
|
|
export function initKeyboardShortcuts(modules) {
|
|
const {
|
|
el, Storage, sessionModule, uiModule, chatModule,
|
|
adminModule, settingsModule, searchChatModule,
|
|
_closeCompareIfActive, _deactivateIncognito, API_BASE
|
|
} = modules;
|
|
|
|
window._odysseusKeybinds = { ..._defaultKeybinds };
|
|
|
|
// Load saved keybinds
|
|
fetch('/api/auth/settings', { credentials: 'same-origin' })
|
|
.then(r => r.json())
|
|
.then(s => { if (s.keybinds) window._odysseusKeybinds = { ..._defaultKeybinds, ...s.keybinds }; })
|
|
.catch(() => {});
|
|
|
|
// ── Esc cancels select mode (capture phase, before modal-close) ──
|
|
// Every tool's bulk-select bar has a `*-bulk-cancel` button whose click
|
|
// already runs the correct teardown (clears selection, hides the bar,
|
|
// re-renders). So a single global handler that clicks whichever cancel
|
|
// button is currently visible covers all of them — notes, skills,
|
|
// memory, gallery, sessions, doc library (chats/archive/research/docs),
|
|
// email, cookbook serve — without each module wiring its own listener.
|
|
// Capture phase + stopPropagation so Esc cancels select instead of
|
|
// closing the surrounding modal.
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Escape') return;
|
|
const cancels = document.querySelectorAll('[id$="-bulk-cancel"]');
|
|
for (const btn of cancels) {
|
|
// Do not rely on offsetParent: visible fixed-position or modal-contained
|
|
// controls can report null. Check the rendered box and hidden ancestors.
|
|
const visible = (() => {
|
|
if (btn.disabled || btn.closest('.hidden,[hidden]')) return false;
|
|
const cs = getComputedStyle(btn);
|
|
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
return btn.offsetWidth > 0 || btn.offsetHeight > 0 || btn.getClientRects().length > 0;
|
|
})();
|
|
if (visible) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
btn.click();
|
|
return;
|
|
}
|
|
}
|
|
}, true);
|
|
|
|
// ── "Toggle Window" — close whatever tool window is open, or reopen the
|
|
// last one. Maps each window's modal element to the button/title that
|
|
// opens it (mirrors modalManager's _AUTO_WIRE, plus email's section title).
|
|
const _WINDOW_TRIGGERS = {
|
|
'settings-modal': 'user-bar-settings',
|
|
'theme-modal': 'tool-theme-btn',
|
|
'tasks-modal': 'tool-tasks-btn',
|
|
'notes-panel': 'tool-notes-btn',
|
|
'memory-modal': 'tool-memory-btn',
|
|
'doclib-modal': 'tool-library-btn',
|
|
'gallery-modal': 'tool-gallery-btn',
|
|
'research-overlay': 'tool-research-btn',
|
|
'cookbook-modal': 'tool-cookbook-btn',
|
|
'compare-model-overlay': 'tool-compare-btn',
|
|
'calendar-modal': 'tool-calendar-btn',
|
|
'email-lib-modal': 'email-section-title',
|
|
};
|
|
let _lastWindow = 'settings-modal';
|
|
|
|
const _windowVisible = (id) => {
|
|
const m = document.getElementById(id);
|
|
if (!m || m.classList.contains('hidden')) return false;
|
|
const cs = getComputedStyle(m);
|
|
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
return m.offsetWidth > 0 || m.offsetHeight > 0 || m.getClientRects().length > 0;
|
|
};
|
|
|
|
const _toggleActiveWindow = () => {
|
|
// Close the first open window (remembering it), else reopen the last one.
|
|
let openId = null;
|
|
for (const id in _WINDOW_TRIGGERS) {
|
|
if (_windowVisible(id)) { openId = id; break; }
|
|
}
|
|
if (openId) {
|
|
_lastWindow = openId;
|
|
const m = document.getElementById(openId);
|
|
const closeBtn = m && m.querySelector('.close-btn, .modal-close, [data-close]');
|
|
if (closeBtn) closeBtn.click();
|
|
else if (openId === 'settings-modal' && settingsModule) settingsModule.close();
|
|
else { const t = el(_WINDOW_TRIGGERS[openId]); if (t) t.click(); }
|
|
} else if (_lastWindow === 'settings-modal') {
|
|
if (settingsModule) settingsModule.open();
|
|
} else {
|
|
const t = el(_WINDOW_TRIGGERS[_lastWindow]);
|
|
if (t) t.click();
|
|
else if (settingsModule) settingsModule.open();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
const kb = window._odysseusKeybinds;
|
|
|
|
if (_matchesCombo(e, kb.search)) {
|
|
e.preventDefault();
|
|
if (searchChatModule) {
|
|
searchChatModule.isOpen() ? searchChatModule.closeSearch() : searchChatModule.openSearch();
|
|
}
|
|
return;
|
|
}
|
|
if (_matchesCombo(e, kb.toggle_sidebar)) {
|
|
e.preventDefault();
|
|
var sb = document.getElementById('sidebar');
|
|
var ir = document.getElementById('icon-rail');
|
|
if (sb && !sb.classList.contains('hidden')) {
|
|
sb.classList.add('hidden');
|
|
} else {
|
|
if (ir) ir.classList.remove('rail-hidden');
|
|
if (sb) sb.classList.remove('hidden');
|
|
}
|
|
if (typeof syncRailSide === 'function') syncRailSide();
|
|
return;
|
|
}
|
|
if (_matchesCombo(e, kb.tts)) {
|
|
e.preventDefault();
|
|
var mgr = window.aiTTSManager;
|
|
if (!mgr || !mgr.available) return;
|
|
if (mgr.isPlaying || mgr._processing) { mgr.stop(); return; }
|
|
var allAI = document.querySelectorAll('#chat-history .msg-ai');
|
|
for (var i = allAI.length - 1; i >= 0; i--) {
|
|
var ttsBtn = allAI[i].querySelector('.ai-tts-button');
|
|
if (ttsBtn) { ttsBtn.click(); return; }
|
|
}
|
|
return;
|
|
}
|
|
if (_matchesCombo(e, kb.fav_session)) {
|
|
e.preventDefault();
|
|
const sid = sessionModule && sessionModule.getCurrentSessionId();
|
|
if (!sid) return;
|
|
const s = sessionModule.getSessions().find(x => x.id === sid);
|
|
if (!s) return;
|
|
const newVal = !s.is_important;
|
|
const fd = new FormData();
|
|
fd.append('important', newVal);
|
|
fetch(`${API_BASE}/api/session/${sid}/important`, { method: 'POST', body: fd });
|
|
s.is_important = newVal;
|
|
sessionModule.renderSessionList();
|
|
uiModule.showToast(newVal ? 'Session favorited' : 'Session unfavorited');
|
|
return;
|
|
}
|
|
if (_matchesCombo(e, kb.delete_session)) {
|
|
e.preventDefault();
|
|
const sid = sessionModule && sessionModule.getCurrentSessionId();
|
|
if (!sid) return;
|
|
const s = sessionModule.getSessions().find(x => x.id === sid);
|
|
if (!s) return;
|
|
if (s.is_important) { uiModule.showToast('Unstar before deleting'); return; }
|
|
uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true }).then(ok => {
|
|
if (!ok) return;
|
|
const allSessions = sessionModule.getSessions();
|
|
const idx = allSessions.findIndex(x => x.id === sid);
|
|
const nextSession = allSessions.filter(x => !x.archived && x.id !== sid)[Math.max(0, idx)] ||
|
|
allSessions.find(x => !x.archived && x.id !== sid);
|
|
fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' }).then(async () => {
|
|
await sessionModule.loadSessions();
|
|
if (nextSession) {
|
|
await sessionModule.selectSession(nextSession.id);
|
|
} else {
|
|
sessionModule.setCurrentSessionId(null);
|
|
el('chat-history').innerHTML = '';
|
|
el('current-meta').textContent = 'Odysseus Chat';
|
|
Storage.remove('lastSessionId');
|
|
if (chatModule && chatModule.showWelcomeScreen) chatModule.showWelcomeScreen();
|
|
}
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
if (_matchesCombo(e, kb.new_session)) {
|
|
e.preventDefault();
|
|
if (_closeCompareIfActive()) return;
|
|
_deactivateIncognito();
|
|
const sid = sessionModule && sessionModule.getCurrentSessionId();
|
|
const sessions = sessionModule ? sessionModule.getSessions() : [];
|
|
const cur = sessions.find(s => s.id === sid);
|
|
const name = new Date().toLocaleTimeString();
|
|
const fd = new FormData();
|
|
fd.append('name', name);
|
|
fd.append('endpoint_url', cur ? cur.endpoint_url || '' : '');
|
|
fd.append('model', cur ? cur.model || '' : '');
|
|
if (cur && cur.endpoint_id) fd.append('endpoint_id', cur.endpoint_id);
|
|
fd.append('skip_validation', 'true');
|
|
fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd, credentials: 'same-origin' })
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(async data => {
|
|
if (data) {
|
|
await sessionModule.loadSessions();
|
|
await sessionModule.selectSession(data.id);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (_matchesCombo(e, kb.cancel)) {
|
|
if (chatModule) chatModule.abortCurrentRequest();
|
|
}
|
|
if (_matchesCombo(e, kb.incognito)) {
|
|
e.preventDefault();
|
|
// Drive the visible button so the real toggle logic runs (visual
|
|
// state, welcome-screen guard, checkbox sync) — flipping the hidden
|
|
// checkbox alone did nothing.
|
|
const btn = el('incognito-btn');
|
|
if (btn) btn.click();
|
|
return;
|
|
}
|
|
if (_matchesCombo(e, kb.settings)) {
|
|
e.preventDefault();
|
|
_toggleActiveWindow();
|
|
return;
|
|
}
|
|
// Open-tool shortcuts — click the sidebar tool button so each tool's
|
|
// own open/toggle logic runs. Unbound (empty) combos never match.
|
|
const _toolBtns = {
|
|
open_calendar: 'tool-calendar-btn',
|
|
open_compare: 'tool-compare-btn',
|
|
open_cookbook: 'tool-cookbook-btn',
|
|
open_research: 'tool-research-btn',
|
|
open_gallery: 'tool-gallery-btn',
|
|
open_library: 'tool-library-btn',
|
|
open_memory: 'tool-memory-btn',
|
|
open_notes: 'tool-notes-btn',
|
|
open_tasks: 'tool-tasks-btn',
|
|
open_theme: 'tool-theme-btn',
|
|
};
|
|
for (const action in _toolBtns) {
|
|
if (_matchesCombo(e, kb[action])) {
|
|
e.preventDefault();
|
|
const b = el(_toolBtns[action]);
|
|
if (b) b.click();
|
|
return;
|
|
}
|
|
}
|
|
if (_matchesCombo(e, kb.focus_input)) {
|
|
e.preventDefault();
|
|
const inp = el('message');
|
|
if (inp) inp.focus();
|
|
return;
|
|
}
|
|
});
|
|
}
|