|
| 1 | +import * as $ from 'jquery-slim'; |
| 2 | +import * as styles from "../scss/content.scss"; |
| 3 | +import * as IMessage from "../IMessage"; |
| 4 | +import Client from "./BackgroundClient"; |
| 5 | +import FieldSet from "./FieldSet"; |
| 6 | +import PageControl from "./PageControl"; |
| 7 | + |
| 8 | +const copyIcon = require('../assets/copy_to_clipboard.svg') |
| 9 | + |
| 10 | + |
| 11 | +/** A dropdown that displays the available credentials and allows to choose between them. */ |
| 12 | +export default class CredentialsDropdown { |
| 13 | + /** The actual dropdown. */ |
| 14 | + private _dropdown?: JQuery; |
| 15 | + /** The credential items shown in the dropdown .*/ |
| 16 | + private _credentialItems?: JQuery[]; |
| 17 | + /** The field set that the drawer is opened for. */ |
| 18 | + private _fieldSet?: FieldSet; |
| 19 | + /** Window resize handler. */ |
| 20 | + private readonly _RESIZE_HANDLER = (_event: JQuery.ResizeEvent) => this._reposition(); |
| 21 | + |
| 22 | + /** |
| 23 | + * @param _pageControl The current page controller. |
| 24 | + */ |
| 25 | + constructor(private readonly _pageControl: PageControl) { |
| 26 | + } |
| 27 | + |
| 28 | + /** |
| 29 | + * @return Whether or not the dropdown is currently opened. |
| 30 | + */ |
| 31 | + public get isOpen(): boolean { |
| 32 | + return this._dropdown !== undefined; |
| 33 | + } |
| 34 | + |
| 35 | + /** |
| 36 | + * @return Whether or not the event caused an element in the dropdown to gain focus. |
| 37 | + */ |
| 38 | + public hasGainedFocus(event: JQuery.FocusOutEvent): boolean { |
| 39 | + return event.relatedTarget instanceof HTMLElement && this._dropdown?.has(event.relatedTarget).length != 0; |
| 40 | + } |
| 41 | + |
| 42 | + /** |
| 43 | + * Open the credentials dropdown. |
| 44 | + * @param fieldSet The field set to open the credential drawer for. |
| 45 | + */ |
| 46 | + public open(fieldSet: FieldSet) { |
| 47 | + if (this.isOpen) { |
| 48 | + if (fieldSet === this._fieldSet) { |
| 49 | + return; // Dropdown is already open |
| 50 | + } |
| 51 | + this.close(); |
| 52 | + } |
| 53 | + const theme = this._pageControl.settings.theme; |
| 54 | + // Create the dropdown |
| 55 | + this._dropdown = $('<div>').addClass(styles.dropdown).css({ |
| 56 | + left: `0px`, |
| 57 | + top: `0px`, |
| 58 | + 'margin-bottom': `${Math.max(theme.dropdownShadowWidth, 2)}px`, |
| 59 | + 'margin-right': `${Math.max(theme.dropdownShadowWidth, 2)}px`, |
| 60 | + 'margin-left': `${Math.max(theme.dropdownShadowWidth, 2)}px`, |
| 61 | + 'border-width': `${theme.dropdownBorderWidth}px`, |
| 62 | + 'box-shadow': `0 ${theme.dropdownShadowWidth}px ${theme.dropdownShadowWidth}px 0 rgba(0,0,0,0.2)`, |
| 63 | + }); |
| 64 | + this._fieldSet = fieldSet; |
| 65 | + let style = this._dropdown.get(0).style; |
| 66 | + style.setProperty('--dropdown-select-background-start', theme.dropdownSelectedItemColorStart); |
| 67 | + style.setProperty('--dropdown-select-background-end', theme.dropdownSelectedItemColorEnd); |
| 68 | + style.setProperty('--scrollbar-color', theme.dropdownScrollbarColor); |
| 69 | + |
| 70 | + // Generate the content |
| 71 | + const content = $('<div>').addClass(styles.content); |
| 72 | + this._generateDropdownContent(content, fieldSet.getCredentials()); |
| 73 | + this._dropdown.append(content); |
| 74 | + |
| 75 | + if (theme.enableDropdownFooter) { |
| 76 | + // Create the footer and add it to the dropdown |
| 77 | + // noinspection HtmlRequiredAltAttribute,RequiredAttributes |
| 78 | + const footerItems: (JQuery | string)[] = [ |
| 79 | + $('<img>').addClass(styles.logo).attr('src', chrome.extension.getURL('images/icon48.png')) |
| 80 | + .attr('alt', ''), |
| 81 | + 'ChromeKeePass', |
| 82 | + $('<img>').attr('src', chrome.extension.getURL('images/gear.png')).attr('tabindex', '0') |
| 83 | + .attr('alt', 'Open Settings').attr('title', 'Open settings').css({cursor: 'pointer'}) |
| 84 | + .on('click', this._openOptionsWindow.bind(this)).on('focusout', this._onItemFocusLost.bind(this)), |
| 85 | + // $('<img>').attr('src', chrome.extension.getURL('images/key.png')).attr('title', 'Generate password').css({cursor: 'pointer'}), |
| 86 | + ]; |
| 87 | + const footer = $('<div>').addClass(styles.footer).append(...footerItems); |
| 88 | + this._dropdown.append(footer); |
| 89 | + } |
| 90 | + // Show the dropdown |
| 91 | + $(document.body).append(this._dropdown); |
| 92 | + this._reposition(); |
| 93 | + $(window).on('resize', this._RESIZE_HANDLER); |
| 94 | + } |
| 95 | + |
| 96 | + /** Close the dropdown. */ |
| 97 | + public close() { |
| 98 | + if (this._dropdown) { |
| 99 | + $(window).off('resize', this._RESIZE_HANDLER); |
| 100 | + this._dropdown.remove(); |
| 101 | + this._credentialItems = undefined; |
| 102 | + this._fieldSet?.selectCredential(undefined); |
| 103 | + this._fieldSet = undefined; |
| 104 | + this._dropdown = undefined; |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + /** |
| 109 | + * Set the list of credentials that are shown in the dropdown. |
| 110 | + * @param credentials The list of credentials. |
| 111 | + */ |
| 112 | + public setCredentials(credentials: IMessage.Credential[]) { |
| 113 | + if (this._dropdown === undefined) { |
| 114 | + return; |
| 115 | + } |
| 116 | + const target = this._dropdown.find(`.${styles.content}`); |
| 117 | + this._generateDropdownContent(target, credentials); |
| 118 | + } |
| 119 | + |
| 120 | + /** |
| 121 | + * Select the next credential in the list. |
| 122 | + * @param reverse Whether to select the previous or next. |
| 123 | + */ |
| 124 | + public selectNextCredential(reverse?: boolean) { |
| 125 | + if (!(this._credentialItems && this._credentialItems.length)) { // There is something available? |
| 126 | + return; |
| 127 | + } |
| 128 | + let selectedIndex = this._credentialItems.findIndex((item) => item.hasClass(styles.selected)); |
| 129 | + if (selectedIndex == -1) { |
| 130 | + selectedIndex = 0; |
| 131 | + } else { |
| 132 | + this._credentialItems[selectedIndex].removeClass(styles.selected); |
| 133 | + if (!reverse) { |
| 134 | + selectedIndex = ++selectedIndex % this._credentialItems.length; |
| 135 | + } else if (--selectedIndex < 0) { // Jump back to the last item if we get past the first item |
| 136 | + selectedIndex = this._credentialItems.length - 1; |
| 137 | + } |
| 138 | + } |
| 139 | + this._credentialItems[selectedIndex].addClass(styles.selected); |
| 140 | + this._credentialItems[selectedIndex].get(0).scrollIntoView({ |
| 141 | + behavior: "auto", |
| 142 | + block: "nearest" |
| 143 | + }); |
| 144 | + this._fieldSet?.selectCredential(this._credentialItems[selectedIndex].data('credential')); |
| 145 | + } |
| 146 | + |
| 147 | + /** Recalculate the position of the dropdown. */ |
| 148 | + private _reposition() { |
| 149 | + if (this._dropdown === undefined || this._fieldSet === undefined) { |
| 150 | + return; |
| 151 | + } |
| 152 | + const target = this._fieldSet.controlField; |
| 153 | + if (target === undefined) { |
| 154 | + return; |
| 155 | + } |
| 156 | + const documentBody = $(document.body); |
| 157 | + const bodyIsOffsetParent = this._dropdown.offsetParent().get(0) === document.body; |
| 158 | + const bodyWidth = Math.max(documentBody.outerWidth(bodyIsOffsetParent) || 0, window.innerWidth); |
| 159 | + const bodyHeight = Math.max(documentBody.outerHeight(bodyIsOffsetParent) || 0, window.innerHeight); |
| 160 | + const targetOffset = target.offset(); |
| 161 | + const theme = this._pageControl.settings.theme; |
| 162 | + const minWidth = 225; |
| 163 | + const targetWidth = target.outerWidth() || minWidth; |
| 164 | + let left = (targetOffset?.left || 0) - Math.max(theme.dropdownShadowWidth, 2); |
| 165 | + if (targetWidth < minWidth) { |
| 166 | + left -= (minWidth - targetWidth) / 2.0; |
| 167 | + } |
| 168 | + if (left < scrollX) { |
| 169 | + left = scrollX - Math.max(theme.dropdownShadowWidth, 2); |
| 170 | + } else if (left + scrollX + minWidth > bodyWidth) { |
| 171 | + left = bodyWidth - minWidth - Math.max(theme.dropdownShadowWidth, 2); |
| 172 | + } |
| 173 | + let top = (targetOffset?.top || 0) + (target.outerHeight() || 10); |
| 174 | + const dropdownHeight = this._dropdown.outerHeight(true) || 0; |
| 175 | + if (top - scrollY + dropdownHeight > bodyHeight) { |
| 176 | + const offset = dropdownHeight + (target.outerHeight() || 0); |
| 177 | + if (bodyHeight - top >= top || top - offset < scrollY) { |
| 178 | + top = scrollY + bodyHeight - dropdownHeight; |
| 179 | + } else { |
| 180 | + top -= offset; |
| 181 | + } |
| 182 | + } |
| 183 | + if (bodyIsOffsetParent) { |
| 184 | + top -= parseFloat(documentBody.css('marginTop')) + parseFloat(documentBody.css('borderTopWidth')); |
| 185 | + left -= parseFloat(documentBody.css('marginLeft')) + parseFloat(documentBody.css('borderLeftWidth')); |
| 186 | + } |
| 187 | + this._dropdown.css({ |
| 188 | + left: `${left}px`, |
| 189 | + top: `${top}px`, |
| 190 | + width: `${targetWidth}px`, |
| 191 | + }); |
| 192 | + } |
| 193 | + |
| 194 | + /** |
| 195 | + * Generate the html for the dropdown content. |
| 196 | + * |
| 197 | + * @param container The container for the credential items. |
| 198 | + * @param credentials The credentials to show in the dropdown. |
| 199 | + */ |
| 200 | + private _generateDropdownContent(container: JQuery, credentials: IMessage.Credential[]) { |
| 201 | + if (credentials.length) { |
| 202 | + const items: JQuery[] = []; |
| 203 | + credentials.forEach((credential) => { |
| 204 | + items.push( |
| 205 | + $('<div>').data('credential', credential).addClass(styles.item).attr('tabindex', '0').css( |
| 206 | + {'padding': `${this._pageControl.settings.theme.dropdownItemPadding}px`}).append( |
| 207 | + $('<div>').addClass(styles.primaryText).text(credential.title) |
| 208 | + ).append( |
| 209 | + $('<div>').text(credential.username) |
| 210 | + ).on('click', this._onClickCredential.bind(this)).on('focusout', this._onItemFocusLost.bind(this)) |
| 211 | + ); |
| 212 | + }); |
| 213 | + this._credentialItems = items; |
| 214 | + container.empty().append(items); |
| 215 | + |
| 216 | + if(items.length === 1) // Is there only one item? |
| 217 | + this.selectNextCredential(); // Select it |
| 218 | + |
| 219 | + } else { // No credentials available |
| 220 | + this._credentialItems = undefined; |
| 221 | + this._fieldSet?.selectCredential(undefined); |
| 222 | + container.empty().append($('<div>').addClass(styles.noResults).text('No credentials found')); |
| 223 | + if (self != top) { |
| 224 | + const iframeInfo = $('<div>').addClass(styles.iframeInfo); |
| 225 | + iframeInfo.append($('<div>').text( |
| 226 | + 'This input is part of a website that is embedded into the current website. ' + |
| 227 | + 'Your passwords should be registered with the following URL:')); |
| 228 | + |
| 229 | + const urlInput = $('<input>').attr('readonly', 'readonly').attr('type', 'url') |
| 230 | + .val(self.location.origin); |
| 231 | + const copyToClipboardIcon = $('<div>').addClass(styles.copyIcon).html(copyIcon) |
| 232 | + .attr('title', 'Copy to clipboard').attr('tabindex', '0') |
| 233 | + .on('click', (event)=>{ |
| 234 | + event.preventDefault(); |
| 235 | + this._copyIframeUrl(copyToClipboardIcon, urlInput); |
| 236 | + }); |
| 237 | + iframeInfo.append($('<div>').attr('class', styles.inputWrapper) |
| 238 | + .append(urlInput).append(copyToClipboardIcon) |
| 239 | + ); |
| 240 | + container.append(iframeInfo); |
| 241 | + } |
| 242 | + } |
| 243 | + } |
| 244 | + |
| 245 | + /** Open the extension's option window. */ |
| 246 | + private _openOptionsWindow() { |
| 247 | + Client.openOptions(); |
| 248 | + this.close(); |
| 249 | + } |
| 250 | + |
| 251 | + /** |
| 252 | + * Copy the url of the current iframe into the clipboard. |
| 253 | + * @param icon The icon that was clicked. |
| 254 | + * @param urlInput The input element that contains the url of the current iframe. |
| 255 | + */ |
| 256 | + private _copyIframeUrl(icon: JQuery, urlInput: JQuery) { |
| 257 | + urlInput.trigger('select'); |
| 258 | + const success = document.execCommand('copy'); |
| 259 | + if (success) { |
| 260 | + icon.addClass(styles.success); |
| 261 | + setTimeout(() => icon.removeClass(styles.success), 3000); |
| 262 | + } |
| 263 | + this._fieldSet?.controlField?.trigger('focus'); |
| 264 | + } |
| 265 | + |
| 266 | + /** Handle a click on a credential field. */ |
| 267 | + private _onClickCredential(event: JQuery.ClickEvent) { |
| 268 | + this._fieldSet?.selectCredential($(event.target).closest(`.${styles.item}`).data('credential')); |
| 269 | + this._fieldSet?.enterSelection(); |
| 270 | + } |
| 271 | + |
| 272 | + /** |
| 273 | + * Handle a focus lost event on one of the credentials items. |
| 274 | + * @param event The focus out event. |
| 275 | + */ |
| 276 | + private _onItemFocusLost(event: JQuery.FocusOutEvent) { |
| 277 | + if (!this.hasGainedFocus(event) && (event.relatedTarget === undefined |
| 278 | + || event.relatedTarget !== this._fieldSet?.controlField?.get(0))) { |
| 279 | + this.close(); |
| 280 | + } |
| 281 | + } |
| 282 | +} |
0 commit comments