Appearance
@@ -610,6 +619,12 @@
+
+
+ Quick search picker (find entry, fill from anywhere)
+
+
+
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
diff --git a/js/app.js b/js/app.js
index 5fc56d7..a2977ea 100644
--- a/js/app.js
+++ b/js/app.js
@@ -149,9 +149,14 @@ const Bridge = (() => {
if (!active) return;
const f = autofillComboToWin32(combos.full);
const p = autofillComboToWin32(combos.password);
+ let qs = '';
+ if (combos.quickSearch) {
+ const q = autofillComboToWin32(combos.quickSearch);
+ qs = '&qs_mods=' + q.mods + '&qs_vk=' + q.vk;
+ }
cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') +
'&full_mods=' + f.mods + '&full_vk=' + f.vk +
- '&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk);
+ '&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs);
},
// Called by Delphi after a setAutofillHotkeys request, with true if
@@ -532,6 +537,8 @@ const state = {
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'),
autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') ||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
+ quickSearchHotkey: JSON.parse(localStorage.getItem('quickSearchHotkey') ||
+ '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"Q"}'),
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
'{"folders":false,"tags":false,"tools":false}'),
// Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by
@@ -4432,16 +4439,13 @@ async function openSlideOver(id, opts) {
// Working copy of the custom-fields array — mutated in place by
// buildCustomFieldRow handlers. The serialized JSON of this array
// at Save time is what gets encrypted into custom_fields/iv.
- customFields: plainCustom.map(f => {
- const out = {
- label: f.label || '', value: f.value || '',
- is_secret: !!f.is_secret,
- };
- if (Array.isArray(f.options) && f.options.length > 0)
- out.options = f.options.slice();
- return out;
- }),
- originalCustomJson: JSON.stringify(plainCustom),
+ // customFields + originalCustomJson are assigned just after soState
+ // is constructed (see below) so both sides of the dirty check use
+ // the SAME normalized shape — comparing raw plainCustom against the
+ // mapped working copy would falsely fire dirty on entries whose
+ // stored blob carries extra/legacy keys.
+ customFields: [],
+ originalCustomJson: '[]',
originalEncrypted: isNew ? null : e.encrypted_password,
originalIV: isNew ? null : e.iv,
originalTotpEncrypted: isNew ? null : e.totp_secret,
@@ -4459,6 +4463,21 @@ async function openSlideOver(id, opts) {
pendingAttachments: [],
};
+ // Normalize the custom-fields array ONCE — the working copy and the
+ // dirty-check baseline must share the same shape, otherwise stripped
+ // legacy keys (empty options[], stray metadata) make the JSON diverge
+ // on open and the entry looks dirty without any user input.
+ soState.customFields = plainCustom.map(f => {
+ const out = {
+ label: f.label || '', value: f.value || '',
+ is_secret: !!f.is_secret,
+ };
+ if (Array.isArray(f.options) && f.options.length > 0)
+ out.options = f.options.slice();
+ return out;
+ });
+ soState.originalCustomJson = JSON.stringify(soState.customFields);
+
if (isNote) {
// Notes: minimal layout — name + multiline body + folder + tags.
// No icon (covered by sidebar icon), no site/user/totp.
@@ -4603,6 +4622,7 @@ function buildCustomFieldRow(field, idx, rerender) {
const labelInput = el('input', {
type: 'text', class: 'so-input so-custom-label',
placeholder: 'Label (e.g. PIN, Account #)',
+ autocomplete: 'off', spellcheck: 'false',
});
labelInput.value = field.label || '';
labelInput.addEventListener('input', () => {
@@ -4632,6 +4652,8 @@ function buildCustomFieldRow(field, idx, rerender) {
type: field.is_secret ? 'password' : 'text',
class: 'so-input so-custom-value',
placeholder: 'Value',
+ autocomplete: field.is_secret ? 'new-password' : 'off',
+ spellcheck: 'false',
});
valueInput.value = field.value || '';
valueInput.addEventListener('input', () => {
@@ -4711,6 +4733,7 @@ function soNoteBodyField(value) {
class: 'so-input so-note-body',
rows: 12,
placeholder: 'Encrypted with your vault key. Nothing leaves your device.',
+ autocomplete: 'off', autocorrect: 'off', spellcheck: 'false',
});
ta.value = value || '';
wrap.appendChild(ta);
@@ -4720,7 +4743,16 @@ function soNoteBodyField(value) {
function soEditableField(label, id, value) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
- const input = el('input', { type: 'text', id, value, class: 'so-input' });
+ const input = el('input', {
+ type: 'text', id, value, class: 'so-input',
+ // Disable Edge / Chromium "saved form data" history — these
+ // fields can carry titles, usernames and other identifying
+ // info that shouldn't end up in the browser's autocomplete
+ // dropdown (visible via arrow-down on a focused field).
+ autocomplete: 'off',
+ autocorrect: 'off',
+ spellcheck: 'false',
+ });
input.addEventListener('keydown', soOnEnterSave);
wrap.appendChild(input);
return wrap;
@@ -4818,6 +4850,7 @@ function soPasswordField(plain) {
const input = el('input', {
type: 'password', id: 'soPassword', value: plain,
class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace',
+ autocomplete: 'new-password', spellcheck: 'false',
});
input.addEventListener('keydown', soOnEnterSave);
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' });
@@ -4908,6 +4941,7 @@ function soTotpField(plainSecret) {
value: plainSecret || '',
class: 'so-input',
placeholder: 'Paste base32 secret or otpauth:// URI',
+ autocomplete: 'new-password', spellcheck: 'false',
on: { keydown: soOnEnterSave },
style: 'flex:1;font-family:JetBrains Mono,monospace',
autocomplete: 'off', spellcheck: 'false',
@@ -5387,6 +5421,12 @@ function passwordField(plain) {
function closeSlideOver() {
stopTotpTick();
+ // Blur any input inside the slideover BEFORE we hide it — otherwise
+ // focus lingers on an invisible field and Edge's saved-form-data
+ // popup ("Informations enregistrées") can still pop on arrow-down /
+ // backspace, leaking past values to the user-visible UI.
+ const ae = document.activeElement;
+ if (ae && $('#slideover').contains(ae) && typeof ae.blur === 'function') ae.blur();
$('#slideover').classList.remove('is-open');
state.selectedId = null;
renderGrid();
@@ -7909,7 +7949,20 @@ function parseEntriesFromCSV(text) {
// fall back to a heuristic (empty site + non-empty notes = a note).
const colKind = findColumn(headers, ['kind', 'type', 'item_type']);
const colTemplate = findColumn(headers, ['template', 'subtype']);
- const colCustom = findColumn(headers, ['custom_fields', 'custom']);
+ const colCustom = findColumn(headers, ['custom_fields', 'custom', 'fields']);
+ // Bitwarden card columns — only used when type=card. Each maps to a
+ // custom field on a credit-card-template note.
+ const colCardHolder = findColumn(headers, ['card_cardholdername', 'card_holder', 'cardholder']);
+ const colCardBrand = findColumn(headers, ['card_brand', 'card_type']);
+ const colCardNumber = findColumn(headers, ['card_number', 'cardnumber']);
+ const colCardExpM = findColumn(headers, ['card_expmonth', 'card_exp_month']);
+ const colCardExpY = findColumn(headers, ['card_expyear', 'card_exp_year']);
+ const colCardCode = findColumn(headers, ['card_code', 'card_cvv', 'card_cvc']);
+ // Bitwarden identity columns — mapped to identity-template note.
+ const colIdFirst = findColumn(headers, ['identity_firstname']);
+ const colIdLast = findColumn(headers, ['identity_lastname']);
+ const colIdEmail = findColumn(headers, ['identity_email']);
+ const colIdPhone = findColumn(headers, ['identity_phone']);
if (colSite === null && colTitle === null && colUser === null)
throw new Error('No recognizable title/url or username column in CSV header');
@@ -7928,9 +7981,14 @@ function parseEntriesFromCSV(text) {
// CSVs (Bitwarden/KeePass) — when site+user+pwd are all empty but
// notes/title is set, that's a secure-note row.
let kindRaw = (colKind !== null ? String(r[colKind] || '').toLowerCase().trim() : '');
- let kind = (kindRaw === 'note' || kindRaw === 'secure_note') ? 'note' : 'login';
+ let kind = (kindRaw === 'note' || kindRaw === 'secure_note') ? 'note' :
+ (kindRaw === 'card' || kindRaw === 'identity') ? 'note' : 'login';
if (kindRaw === '' && !siteRaw && !pwd && notesRaw) kind = 'note';
- const templateRaw = (colTemplate !== null ? String(r[colTemplate] || '').trim() : '');
+ let templateRaw = (colTemplate !== null ? String(r[colTemplate] || '').trim() : '');
+ // Bitwarden type=card / type=identity → note kind + appropriate
+ // template. The card/identity columns become custom fields below.
+ if (kindRaw === 'card') templateRaw = templateRaw || 'credit-card';
+ if (kindRaw === 'identity') templateRaw = templateRaw || 'identity';
// Notes legitimately have no site; their body is in `notes` (or in
// `password` when round-tripping our own CSV — we wrote the body
@@ -7943,16 +8001,52 @@ function parseEntriesFromCSV(text) {
const raw = String(r[colCustom] || '').trim();
if (raw) {
try {
+ // Our own export: JSON array of {label, value, is_secret}
const arr = JSON.parse(raw);
if (Array.isArray(arr))
cf = arr.filter(f => f && typeof f === 'object' && f.label);
- } catch {}
+ } catch {
+ // Bitwarden / Chrome / KeePass CSV: newline-separated
+ // "label: value" lines (sometimes "label=value"). Split,
+ // pick the FIRST separator only so values can contain
+ // ":" or "=" without being mangled.
+ raw.split(/\r?\n/).forEach(line => {
+ line = line.trim();
+ if (!line) return;
+ const sep = line.search(/[:=]/);
+ if (sep <= 0) return;
+ const label = line.slice(0, sep).trim();
+ const value = line.slice(sep + 1).trim();
+ if (label) cf.push({ label, value, is_secret: false });
+ });
+ }
}
}
if (kind === 'note') {
- const body = pwd || notesRaw;
- if (!body) { skipped++; continue; }
+ // Pull Bitwarden card/identity columns into custom_fields so
+ // the type=card / type=identity rows survive the import.
+ const push = (label, val, is_secret) => {
+ if (val) cf.push({ label, value: val, is_secret: !!is_secret });
+ };
+ if (kindRaw === 'card') {
+ push('Cardholder', colCardHolder !== null ? String(r[colCardHolder] || '').trim() : '');
+ push('Brand', colCardBrand !== null ? String(r[colCardBrand] || '').trim() : '');
+ push('Number', colCardNumber !== null ? String(r[colCardNumber] || '').trim() : '', true);
+ const expM = colCardExpM !== null ? String(r[colCardExpM] || '').trim() : '';
+ const expY = colCardExpY !== null ? String(r[colCardExpY] || '').trim() : '';
+ if (expM || expY) push('Expires', (expM && expY) ? (expM + '/' + expY) : (expM || expY));
+ push('CVV', colCardCode !== null ? String(r[colCardCode] || '').trim() : '', true);
+ }
+ if (kindRaw === 'identity') {
+ const f = colIdFirst !== null ? String(r[colIdFirst] || '').trim() : '';
+ const l = colIdLast !== null ? String(r[colIdLast] || '').trim() : '';
+ if (f || l) push('Name', (f && l) ? (f + ' ' + l) : (f || l));
+ push('Email', colIdEmail !== null ? String(r[colIdEmail] || '').trim() : '');
+ push('Phone', colIdPhone !== null ? String(r[colIdPhone] || '').trim() : '');
+ }
+ const body = pwd || notesRaw || ' '; // template carries data via cf
+ if (!body && cf.length === 0) { skipped++; continue; }
const tagsArr = [];
if (colTags !== null) {
String(r[colTags] || '').split(/[,;]/).forEach(t => {
@@ -7981,9 +8075,9 @@ function parseEntriesFromCSV(text) {
const title = titleRaw || '';
if (!site || !pwd) { skipped++; continue; }
- // Tags: combine the tags column and any free-form notes into a
- // comma-separated string. Notes often contain useful metadata we
- // don't want to drop on the floor.
+ // Tags: only the explicit tags column. Free-form notes are
+ // surfaced as a custom "Notes" field below — putting prose into
+ // the tag chip strip turned it into noise (and lost line breaks).
let tagsArr = [];
if (colTags !== null) {
String(r[colTags] || '').split(/[,;]/).forEach(t => {
@@ -7991,9 +8085,12 @@ function parseEntriesFromCSV(text) {
if (t) tagsArr.push(t);
});
}
+ // Bitwarden / KeePass / Chrome login rows carry per-entry notes
+ // in a `notes` column. Preserve them as a non-secret custom field
+ // so the body survives round-trip without polluting tags.
if (colNotes !== null) {
const n = String(r[colNotes] || '').trim();
- if (n && n.length < 80) tagsArr.push(n); // long notes become noise as tags
+ if (n) cf.push({ label: 'Notes', value: n, is_secret: false });
}
// TOTP: support raw base32 OR full otpauth:// URI in the cell.
@@ -8256,6 +8353,34 @@ async function doImport() {
}
}
+ // CSV imports (Bitwarden / KeePass / Chrome) don't carry a
+ // folders[] block — they just stamp a folder name on each row.
+ // Bulk-import stores the name but never creates the folders
+ // table row, so the sidebar wouldn't show the new folder.
+ // Auto-create any referenced folder that doesn't exist yet.
+ const referenced = new Set();
+ for (const e of parsed.entries) {
+ const f = (e.folder || '').trim();
+ if (f && f !== 'All') referenced.add(f);
+ }
+ if (referenced.size > 0) {
+ const localNames = new Set((state.folders || [])
+ .filter(f => f && f.name).map(f => f.name));
+ let createdMissing = 0;
+ for (const name of referenced) {
+ if (localNames.has(name)) continue;
+ try {
+ await api('/folders', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ name, color: '', icon: '' }),
+ });
+ createdMissing++;
+ } catch (_) { /* duplicate or invalid — skip silently */ }
+ }
+ if (createdMissing > 0) await loadFolders();
+ }
+
toast('Encrypting ' + parsed.entries.length + ' entries…');
const encrypted = [];
@@ -8628,10 +8753,12 @@ function autofillCaptureFromEvent(e) {
function autofillPushHotkeys() {
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
+ localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey));
if (Bridge.active) {
Bridge.setAutofillHotkeys(state.autofillEnabled, {
- full: state.autofillHotkeyFull,
- password: state.autofillHotkeyPwd,
+ full: state.autofillHotkeyFull,
+ password: state.autofillHotkeyPwd,
+ quickSearch: state.quickSearchHotkey,
});
}
}
@@ -8863,6 +8990,7 @@ function openSettings() {
// Hotkey capture buttons — labels reflect current combos.
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
+ $('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
$('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none';
// Start-with-Windows toggle: only meaningful inside the Delphi host
// (registry access). Hide for the PHP frontend.
@@ -8933,11 +9061,83 @@ function openSettings() {
}
$('#settingsPanel').classList.add('is-open');
+ // Reset the search filter every time Settings is re-opened so the
+ // user lands on the full panel, not the last filtered view.
+ const si = $('#settingsSearch');
+ if (si) { si.value = ''; applySettingsSearch(''); }
}
function closeSettings() {
$('#settingsPanel').classList.remove('is-open');
}
+// Filter the settings panel by text. Matches against the label + the
+// section body so e.g. "Ctrl" finds the hotkeys section via its button
+// labels. Empty query = show everything. Adds a "No matches" hint when
+// every section is hidden.
+function applySettingsSearch(rawQuery) {
+ const panel = $('#settingsPanel');
+ if (!panel) return;
+ const wrap = $('.settings-search-wrap');
+ const q = (rawQuery || '').trim().toLowerCase();
+ wrap && wrap.classList.toggle('has-query', q.length > 0);
+
+ const sections = panel.querySelectorAll('.slideover-body > .slideover-field');
+ let totalShownRows = 0;
+
+ sections.forEach(sec => {
+ // No query: reset everything to visible.
+ if (!q) {
+ sec.classList.remove('is-search-hidden');
+ sec.querySelectorAll('.is-search-hidden').forEach(n =>
+ n.classList.remove('is-search-hidden'));
+ return;
+ }
+ // Section label text is part of the section's identity (e.g.
+ // "Sync" or "Security") — a query that hits the label keeps the
+ // whole section visible without per-row filtering.
+ const labelEl = sec.querySelector('.slideover-field-label');
+ const labelTxt = (labelEl ? labelEl.innerText : '').toLowerCase();
+ const labelMatch = labelTxt && labelTxt.indexOf(q) >= 0;
+
+ // Per-row filter: each .setting-row is an individually-matchable
+ // entry. Non-row children (paragraphs, button groups, hints) keep
+ // their default visibility — they're context for whichever row is
+ // shown, not standalone matches.
+ const rows = sec.querySelectorAll(':scope > .setting-row');
+ let rowMatches = 0;
+ rows.forEach(row => {
+ if (labelMatch) {
+ row.classList.remove('is-search-hidden');
+ rowMatches++;
+ return;
+ }
+ const txt = (row.innerText || '').toLowerCase();
+ const hit = txt.indexOf(q) >= 0;
+ row.classList.toggle('is-search-hidden', !hit);
+ if (hit) rowMatches++;
+ });
+
+ // Section has no rows at all (button-only section like Import /
+ // Recovery): match against the whole section text.
+ const sectionHasRows = rows.length > 0;
+ const sectionHit = labelMatch ||
+ (!sectionHasRows && (sec.innerText || '').toLowerCase().indexOf(q) >= 0) ||
+ rowMatches > 0;
+
+ sec.classList.toggle('is-search-hidden', !sectionHit);
+ if (sectionHit) totalShownRows += sectionHasRows ? rowMatches : 1;
+ });
+
+ let banner = panel.querySelector('.settings-no-results');
+ if (!banner) {
+ banner = el('div', { class: 'settings-no-results' },
+ 'No settings match your search.');
+ const body = panel.querySelector('.slideover-body');
+ if (body) body.appendChild(banner);
+ }
+ banner.classList.toggle('is-visible', q.length > 0 && totalShownRows === 0);
+}
+
// ---- Auto-lock with 30s warning countdown -------------------
const WARNING_SECONDS = 30;
let autoLockTimer = null;
@@ -9747,7 +9947,7 @@ const SYNCED_SETTING_KEYS = [
// Hotkey combos are user preferences — values are portable. The
// registration itself is Windows-only, so non-Windows clients just
// ignore them.
- 'autofillHotkeyFull', 'autofillHotkeyPwd',
+ 'autofillHotkeyFull', 'autofillHotkeyPwd', 'quickSearchHotkey',
// Sidebar section collapsed state. Object of { folders, tags, tools }
// booleans. Synced so the user gets the same fold state across devices.
'sidebarCollapsed',
@@ -9811,6 +10011,7 @@ async function loadServerSettings() {
case 'pageSize': localStorage.setItem('pageSize', String(v)); break;
case 'autofillHotkeyFull':
case 'autofillHotkeyPwd':
+ case 'quickSearchHotkey':
case 'sidebarCollapsed':
// Object; persist as JSON so the next cold start picks it up.
localStorage.setItem(k, JSON.stringify(v));
@@ -9982,6 +10183,26 @@ async function init() {
});
document.addEventListener('keydown', handleCardCursorKey);
+ // Delete / Backspace on the grid (no input focused, no modal open)
+ // triggers the batch action matching the current view: soft-trash for
+ // normal views, permanent-delete for the trash view. Mirrors what the
+ // batch bar does, just via keyboard.
+ document.addEventListener('keydown', e => {
+ if (e.key !== 'Delete' && e.key !== 'Backspace') return;
+ if (e.ctrlKey || e.altKey || e.metaKey) return;
+ const tag = (e.target && e.target.tagName || '').toLowerCase();
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
+ if (e.target && e.target.isContentEditable) return;
+ if (!$('#appShell') || $('#appShell').classList.contains('is-hidden')) return;
+ if (document.querySelector('.modal:not(.is-hidden)')) return;
+ if ($('#slideover') && $('#slideover').classList.contains('is-open')) return;
+ if ($('#settingsPanel') && $('#settingsPanel').classList.contains('is-open')) return;
+ if (state.checked.size === 0) return;
+ e.preventDefault();
+ if (state.view === 'trash') batchPermDelete();
+ else batchDelete();
+ });
+
// Auth tabs
$$('.auth-tab').forEach(t => {
t.addEventListener('click', () => {
@@ -10173,13 +10394,16 @@ async function init() {
// an input and ends outside doesn't trigger close (the resulting click
// event has a target outside the slideover even though the user never
// intended to dismiss it).
- // Esc closes the slideover.
+ // Esc closes the slideover. Registered with capture=true so it fires
+ // BEFORE any input-level handler that might call stopPropagation, and
+ // before the browser swallows the keystroke for things like clearing
+ // an active form-autocomplete popup on a focused field.
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (!$('#slideover').classList.contains('is-open')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
requestCloseSlideOver();
- });
+ }, true);
// Click-outside closes too. mousedown origin is captured so a
// drag-selection that starts inside an input and ends outside doesn't
// count as an outside click. Cards/rows/modals/etc. are whitelisted
@@ -10352,6 +10576,41 @@ async function init() {
// Settings slide-over
$('#settingsBtn').addEventListener('click', openSettings);
$('#settingsClose').addEventListener('click', closeSettings);
+
+ // Escape closes the Settings panel — unless the search input is
+ // focused with a non-empty query (in that case its own handler
+ // already swallowed the event and cleared the query). Skip when a
+ // confirm/reauth modal is up so its Escape stays the priority.
+ document.addEventListener('keydown', e => {
+ if (e.key !== 'Escape') return;
+ if (!$('#settingsPanel').classList.contains('is-open')) return;
+ if (document.querySelector('.modal:not(.is-hidden)')) return;
+ closeSettings();
+ });
+
+ // Settings search box: live filter on every input. Escape clears the
+ // query (without closing the panel — the existing Esc handler also
+ // closes settings, but only when focus isn't inside an input).
+ const sInput = $('#settingsSearch');
+ const sClear = $('#settingsSearchClear');
+ if (sInput) {
+ sInput.addEventListener('input', e => applySettingsSearch(e.target.value));
+ sInput.addEventListener('keydown', e => {
+ if (e.key === 'Escape' && sInput.value) {
+ e.stopPropagation();
+ sInput.value = '';
+ applySettingsSearch('');
+ }
+ });
+ }
+ if (sClear) {
+ sClear.addEventListener('click', () => {
+ if (!sInput) return;
+ sInput.value = '';
+ applySettingsSearch('');
+ sInput.focus();
+ });
+ }
// Theme: setTheme already writes localStorage. Capture before/after so
// sync only fires if it actually changed.
$('#settingTheme').addEventListener('change', e => {
@@ -10613,16 +10872,22 @@ async function init() {
finish(true);
return;
}
- // Reject if it collides with the other slot.
- const other = (kind === 'full') ? state.autofillHotkeyPwd : state.autofillHotkeyFull;
- if (JSON.stringify(other) === JSON.stringify(captured)) {
- toast('That combo is already used by the other hotkey', 'warning');
+ // Reject if it collides with any of the other configurable slots.
+ const others = [
+ kind !== 'full' ? state.autofillHotkeyFull : null,
+ kind !== 'password' ? state.autofillHotkeyPwd : null,
+ kind !== 'quickSearch' ? state.quickSearchHotkey : null,
+ ].filter(Boolean);
+ const capJson = JSON.stringify(captured);
+ if (others.some(o => JSON.stringify(o) === capJson)) {
+ toast('That combo is already used by another hotkey', 'warning');
finish(true);
return;
}
// Commit.
- if (kind === 'full') state.autofillHotkeyFull = captured;
- else state.autofillHotkeyPwd = captured;
+ if (kind === 'full') state.autofillHotkeyFull = captured;
+ else if (kind === 'password') state.autofillHotkeyPwd = captured;
+ else /* quickSearch */ state.quickSearchHotkey = captured;
btn.textContent = autofillComboLabel(captured);
finish(false);
autofillPushHotkeys(); // re-register in Delphi
@@ -10633,12 +10898,15 @@ async function init() {
}
bindHotkeyCapture('#settingAutofillFullCombo', 'full');
bindHotkeyCapture('#settingAutofillPwdCombo', 'password');
+ bindHotkeyCapture('#settingQuickSearchCombo', 'quickSearch');
$('#settingAutofillResetHotkeys').addEventListener('click', () => {
state.autofillHotkeyFull = { ctrl: true, shift: true, alt: false, win: false, key: 'L' };
state.autofillHotkeyPwd = { ctrl: true, shift: true, alt: false, win: false, key: 'P' };
+ state.quickSearchHotkey = { ctrl: true, shift: true, alt: false, win: false, key: 'Q' };
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
+ $('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
autofillPushHotkeys();
saveServerSettings();
toast('Hotkeys reset to defaults');