diff --git a/keepassxc-browser/content/autocomplete.js b/keepassxc-browser/content/autocomplete.js index bd2d239..a126bc8 100644 --- a/keepassxc-browser/content/autocomplete.js +++ b/keepassxc-browser/content/autocomplete.js @@ -9,19 +9,20 @@ class Autocomplete { this.elements = []; this.index = -1; this.input = undefined; - this.shadowRoot = undefined; this.wrapper = undefined; + this.shadowRoot = undefined; + this.container = undefined; } clear() { this.elements = []; } - async click(e, input) { + async click(e) { } - async itemClick(e, item, input, uuid) { + async itemClick(e, input, uuid) { } @@ -37,8 +38,9 @@ class Autocomplete { this.autoSubmit = autoSubmit; if (!this.autocompleteList.includes(input)) { - input.addEventListener('click', ev => this.click(ev, input)); - input.addEventListener('keydown', ev => this.keyPress(ev)); + input.addEventListener('click', e => this.click(e)); + input.addEventListener('keydown', e => this.keyDown(e)); + input.addEventListener('keyup', e => this.keyUp(e)); input.setAttribute('autocomplete', 'off'); this.autocompleteList.push(input); } @@ -48,17 +50,21 @@ class Autocomplete { } } - mouseOver(e, div, item) { - this.removeItem(this.getAllItems()); - item.classList.add('kpxcAutocomplete-active'); - this.index = Array.from(div.childNodes).indexOf(item); - } - - mouseOut(e, item) { - item.classList.remove('kpxcAutocomplete-active'); + mouseMove(e) { + if (e.movementX === 0 && e.movementY === 0) { + return; + } + this.deselectItem(); + e.target.classList.add('kpxcAutocomplete-active'); + const items = this.getAllItems(); + this.index = Array.from(items).indexOf(e.target); } async showList(inputField) { + if (this.input === inputField) { + return; + } + this.closeList(); // Return if there are no credentials @@ -67,104 +73,80 @@ class Autocomplete { } this.input = inputField; - this.input.select(); - const div = kpxcUI.createElement('div', 'kpxcAutocomplete-items', { 'id': 'kpxcAutocomplete-list' }); - initColorTheme(div); + if (!this.wrapper) { + const styleSheet = createStylesheet('css/autocomplete.css'); + const colorStyleSheet = createStylesheet('css/colors.css'); + this.wrapper = kpxcUI.createElement('div'); + this.container = kpxcUI.createElement('div', 'kpxcAutocomplete-container', { 'id': 'kpxcAutocomplete-container' }); - this.updatePosition(inputField, div); - div.style.zIndex = '2147483646'; + this.shadowRoot = this.wrapper.attachShadow({ mode: 'closed' }); + this.shadowRoot.append(colorStyleSheet); + this.shadowRoot.append(styleSheet); - const styleSheet = createStylesheet('css/autocomplete.css'); - const colorStyleSheet = createStylesheet('css/colors.css'); - const wrapper = kpxcUI.createElement('div'); + this.list = kpxcUI.createElement('div', 'kpxcAutocomplete-items', { 'id': 'kpxcAutocomplete-list' }); + initColorTheme(this.list); - this.shadowRoot = wrapper.attachShadow({ mode: 'closed' }); - this.shadowRoot.append(colorStyleSheet); - this.shadowRoot.append(styleSheet); - this.shadowRoot.append(div); - this.wrapper = wrapper; - document.body.append(wrapper); + this.container.append(this.list); + this.shadowRoot.append(this.container); + document.body.append(this.wrapper); - await kpxc.updateTOTPList(); - for (const c of this.elements) { - const item = document.createElement('div'); - item.textContent += c.label; - const itemInput = kpxcUI.createElement('input', '', { 'type': 'hidden', 'value': c.value }); - item.append(itemInput); - item.addEventListener('click', ev => this.itemClick(ev, item, inputField, c.uuid)); + await kpxc.updateTOTPList(); + for (const c of this.elements) { + const item = document.createElement('div'); + item.textContent = c.label; + const itemInput = kpxcUI.createElement('input', '', { 'type': 'hidden', 'value': c.value }); + item.append(itemInput); + item.addEventListener('click', e => this.itemClick(e, this.input, c.uuid)); - // These events prevent the double hover effect if both keyboard and mouse are used - item.addEventListener('mouseover', ev => this.mouseOver(ev, div, item)); - item.addEventListener('mouseout', ev => this.mouseOut(ev, item)); + // These events prevent the double hover effect if both keyboard and mouse are used + item.addEventListener('mousemove', e => this.mouseMove(e)); - item.addEventListener('mousedown', ev => ev.stopPropagation()); - item.addEventListener('mouseup', ev => ev.stopPropagation()); + item.addEventListener('mousedown', e => e.stopPropagation()); + item.addEventListener('mouseup', e => e.stopPropagation()); - div.appendChild(item); + this.list.appendChild(item); + } + + // Add a footer message for auto-submit + if (this.autoSubmit) { + const footer = kpxcUI.createElement('footer', '', {}, tr('autocompleteSubmitMessage')); + this.container.appendChild(footer); + } } - // Add a footer message for auto-submit - if (this.autoSubmit) { - const footer = kpxcUI.createElement('footer', '', {}, tr('autocompleteSubmitMessage')); - div.appendChild(footer); - } + this.updatePosition(); + this.container.style.display = 'block'; + } - // Activate the first item automatically + selectItem() { + this.deselectItem(); const items = this.getAllItems(); - this.index = 0; - this.activateItem(items); - } - - activateItem(item) { - if (!item || item.length === 0) { - return; - } - - this.removeItem(item); - if (this.index >= item.length) { - this.index = 0; - } - - if (this.index < 0) { - this.index = item.length - 1; - } - - if (item[this.index] !== undefined) { - item[this.index].classList.add('kpxcAutocomplete-active'); + const item = items[this.index]; + if (item !== undefined) { + item.classList.add('kpxcAutocomplete-active'); + item.scrollIntoView({ block: 'nearest' }); } } - removeItem(items) { + deselectItem() { + const items = this.list.querySelectorAll('div.kpxcAutocomplete-active'); for (const item of items) { item.classList.remove('kpxcAutocomplete-active'); } } - closeList(elem) { + closeList() { + this.input = undefined; if (!this.shadowRoot) { return; } - const items = this.shadowSelectorAll('.kpxcAutocomplete-items'); - if (!items) { - return; - } - - for (const item of items) { - if (elem !== item && this.input) { - item.parentNode.removeChild(item); - } - } + this.container.style.display = 'none'; } getAllItems() { - const list = this.shadowSelector('#kpxcAutocomplete-list'); - if (!list) { - return []; - } - - return list.getElementsByTagName('div'); + return this.list.getElementsByTagName('div'); } /** @@ -174,43 +156,46 @@ class Autocomplete { * - Enter or Tab selects the item * - Backspace and Delete shows the list if input field is empty. First item is activated */ - keyPress(e) { + async keyDown(e) { if (!e.isTrusted) { return; } - const items = this.getAllItems(); const inputField = e.target; if (e.key === 'ArrowDown') { + e.preventDefault(); // If the list is not visible, show it - if (items.length === 0) { - this.index = -1; - this.showList(inputField); + if (!this.input) { + await this.showList(inputField); + this.index = 0; + if (inputField.value !== '') { + this.updateSearch(); + } + requestAnimationFrame(() => this.selectItem()); } else { // Activate next item - ++this.index; - this.activateItem(items); + const items = this.getAllItems(); + this.index = (this.index+1) % items.length; + this.selectItem(); } - } else if (e.key === 'ArrowUp') { - --this.index; - this.activateItem(items); - } else if (e.key === 'Enter') { + } else if (e.key === 'ArrowUp' && this.list) { + e.preventDefault(); + const items = this.getAllItems(); + this.index = (this.index > 0 ? this.index : items.length) - 1; + this.selectItem(); + } else if (e.key === 'Enter' && this.input) { if (inputField.value === '') { e.preventDefault(); } - if (this.index >= 0 && items && items[this.index] !== undefined) { + const items = this.getAllItems(); + if (this.index >= 0 && items[this.index] !== undefined) { e.preventDefault(); - this.itemEnter(this.index, this.elements); + await this.itemEnter(this.index, this.elements); this.closeList(); } - } else if (e.key === 'Tab') { - // Return if the list is not open - if (items.length === 0) { - return; - } - + } else if (e.key === 'Tab' && this.input) { // Return if value is not in the list if (inputField.value !== '' && !this.elements.some(c => c.value === inputField.value)) { this.closeList(); @@ -227,26 +212,45 @@ class Autocomplete { this.closeList(); } else if ((e.key === 'Backspace' || e.key === 'Delete') && inputField.value === '') { // Show menu when input field has no value and backspace is pressed - this.index = -1; this.showList(inputField); + this.index = 0; + this.selectItem(); } } - updatePosition(inputField, elem) { - const div = elem || this.shadowSelector('.kpxcAutocomplete-items'); - if (!div) { + keyUp(e) { + if (!this.input || !e.isTrusted || e.key === 'ArrowDown' || e.key === 'ArrowUp') { return; } - const rect = inputField.getBoundingClientRect(); - div.style.minWidth = Pixels(inputField.offsetWidth); + this.updateSearch(); + this.selectItem(); + } + + updateSearch() { + if (this.index !== -1 && this.elements[this.index]?.value?.includes(this.input.value)) { + return; + } + this.index = this.elements.findIndex(c => c.value.startsWith(this.input.value)); + if (this.index === -1) { + this.index = this.elements.findIndex(c => c.value.includes(this.input.value)); + } + } + + updatePosition() { + if (!this.container || !this.input) { + return; + } + + const rect = this.input.getBoundingClientRect(); + this.container.style.minWidth = Pixels(this.input.offsetWidth); if (kpxcUI.bodyStyle.position.toLowerCase() === 'relative') { - div.style.top = Pixels(rect.top - kpxcUI.bodyRect.top + document.scrollingElement.scrollTop + inputField.offsetHeight); - div.style.left = Pixels(rect.left - kpxcUI.bodyRect.left + document.scrollingElement.scrollLeft); + this.container.style.top = Pixels(rect.top - kpxcUI.bodyRect.top + document.scrollingElement.scrollTop + this.input.offsetHeight); + this.container.style.left = Pixels(rect.left - kpxcUI.bodyRect.left + document.scrollingElement.scrollLeft); } else { - div.style.top = Pixels(rect.top + document.scrollingElement.scrollTop + inputField.offsetHeight); - div.style.left = Pixels(rect.left + document.scrollingElement.scrollLeft); + this.container.style.top = Pixels(rect.top + document.scrollingElement.scrollTop + this.input.offsetHeight); + this.container.style.left = Pixels(rect.left + document.scrollingElement.scrollLeft); } } } @@ -254,25 +258,10 @@ class Autocomplete { // Global handlers const handleOutsideClick = function(e, autocompleteMenu) { - const list = autocompleteMenu.shadowRoot ? autocompleteMenu.shadowSelector('#kpxcAutocomplete-list') : undefined; - if (!list) { - return; - } - if (e.target !== autocompleteMenu.input && !e.target.classList.contains('kpxc-username-icon') - && e.target.nodeName !== autocompleteMenu.input.nodeName) { - autocompleteMenu.closeList(e.target); - - if (autocompleteMenu.wrapper) { - document.body.removeChild(autocompleteMenu.wrapper); - } - } -}; - -const updatePosition = function(autocompleteMenu) { - if (autocompleteMenu.input) { - autocompleteMenu.updatePosition(autocompleteMenu.input); + && e.target !== autocompleteMenu.input) { + autocompleteMenu.closeList(); } }; @@ -292,8 +281,8 @@ window.addEventListener('resize', function() { return; } - updatePosition(kpxcUserAutocomplete); - updatePosition(kpxcTOTPAutocomplete); + kpxcUserAutocomplete.updatePosition(); + kpxcTOTPAutocomplete.updatePosition(); }); // Handle autocomplete position on scroll @@ -302,6 +291,6 @@ window.addEventListener('scroll', function() { return; } - updatePosition(kpxcUserAutocomplete); - updatePosition(kpxcTOTPAutocomplete); + kpxcUserAutocomplete.updatePosition(); + kpxcTOTPAutocomplete.updatePosition(); }); diff --git a/keepassxc-browser/content/credential-autocomplete.js b/keepassxc-browser/content/credential-autocomplete.js index 0975657..833a445 100644 --- a/keepassxc-browser/content/credential-autocomplete.js +++ b/keepassxc-browser/content/credential-autocomplete.js @@ -1,24 +1,21 @@ 'use strict'; class CredentialAutocomplete extends Autocomplete {} -CredentialAutocomplete.prototype.click = async function(e, input) { +CredentialAutocomplete.prototype.click = async function(e) { if (!e.isTrusted) { return; } e.stopPropagation(); - if (input.value !== '') { - input.select(); - } - - const field = this.autocompleteList.find(a => a === input); + const field = this.autocompleteList.find(a => a === e.target); if (field) { - this.showList(field); + await this.showList(field); + this.updateSearch(); } }; -CredentialAutocomplete.prototype.itemClick = async function(e, item, input, uuid) { +CredentialAutocomplete.prototype.itemClick = async function(e, input, uuid) { if (!e.isTrusted) { return; } @@ -26,7 +23,7 @@ CredentialAutocomplete.prototype.itemClick = async function(e, item, input, uuid e.stopPropagation(); const index = Array.prototype.indexOf.call(e.currentTarget.parentElement.childNodes, e.currentTarget); - const usernameValue = item.getElementsByTagName('input')[0].value; + const usernameValue = e.target.getElementsByTagName('input')[0].value; await this.fillPassword(usernameValue, index, uuid); this.closeList(); @@ -46,7 +43,6 @@ CredentialAutocomplete.prototype.fillPassword = async function(value, index, uui const manualFill = await sendMessage('page_get_manual_fill'); await kpxc.fillInCredentials(combination, value, uuid, manualFill === ManualFill.PASSWORD); - this.input.setAttribute('fetched', true); }; const kpxcUserAutocomplete = new CredentialAutocomplete(); diff --git a/keepassxc-browser/content/keepassxc-browser.js b/keepassxc-browser/content/keepassxc-browser.js index 34f077c..5d8b90c 100755 --- a/keepassxc-browser/content/keepassxc-browser.js +++ b/keepassxc-browser/content/keepassxc-browser.js @@ -1110,6 +1110,8 @@ kpxc.fillInCredentials = async function(combination, predefinedUsername, uuid, p } else if (combination.form) { combination.form.submit(); } + } else { + (combination.username || combination.password).focus(); } }; diff --git a/keepassxc-browser/content/totp-autocomplete.js b/keepassxc-browser/content/totp-autocomplete.js index 0ed6de7..f90c9ff 100644 --- a/keepassxc-browser/content/totp-autocomplete.js +++ b/keepassxc-browser/content/totp-autocomplete.js @@ -10,7 +10,7 @@ TOTPAutocomplete.prototype.click = async function(e, input) { this.showList(input, true); }; -TOTPAutocomplete.prototype.itemClick = async function(e, item, input, uuid) { +TOTPAutocomplete.prototype.itemClick = async function(e, input, uuid) { if (!e.isTrusted) { return; } diff --git a/keepassxc-browser/css/autocomplete.css b/keepassxc-browser/css/autocomplete.css index e966466..adb1090 100644 --- a/keepassxc-browser/css/autocomplete.css +++ b/keepassxc-browser/css/autocomplete.css @@ -1,5 +1,10 @@ -.kpxcAutocomplete-items { +#kpxcAutocomplete-container { + display: none; + z-index: 2147483646; position: absolute !important; +} + +.kpxcAutocomplete-items { max-height: 250px; overflow-y: auto; } @@ -23,7 +28,12 @@ border-bottom-left-radius: 4px !important; } -.kpxcAutocomplete-items footer { +.kpxcAutocomplete-active { + background-color: #28a745 !important; + color: #ffffff !important; +} + +#kpxcAutocomplete-container footer { padding: 5px; background-color: #ddd; border: 1px solid rgba(0,0,0,.125) !important; @@ -33,11 +43,6 @@ font-style: italic; } -.kpxcAutocomplete-active { - background-color: #28a745 !important; - color: #ffffff !important; -} - @media (prefers-color-scheme: dark) { .kpxcAutocomplete-items { background: var(--kpxc-background-color);