Skip to content

Commit d5d458b

Browse files
authored
Merge pull request #64 from RoelVB/dev
Release 1.4
2 parents f420447 + 131c162 commit d5d458b

11 files changed

+606
-282
lines changed

dist/html/options.html

+19
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,25 @@ <h2>Theme</h2>
104104
</span>
105105
</span>
106106
</label>
107+
<div class="separator"></div>
108+
<label>
109+
Padding of the items in the credential list dropdown
110+
<span class="range-input">
111+
<span class="value-bubble-container">
112+
<span class="value-bubble">0</span>
113+
</span>
114+
<input id="dropdownItemPadding" type="range" min="0" max="20"/>
115+
<span class="labels">
116+
<span>Smallest</span>
117+
<span>Largest</span>
118+
</span>
119+
</span>
120+
</label>
121+
<div class="separator"></div>
122+
<label>
123+
Scrollbar color in the credential list dropdown
124+
<input id="dropdownScrollbarColor" type="color" title="The color of the scrollbar"/>
125+
</label>
107126
</div>
108127
<h2>Shortcuts</h2>
109128
<div class="group-container">

dist/manifest.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
"name": "ChromeKeePass",
55
"description": "Chrome extension for automatically entering credentials from KeePass/KeeWeb",
6-
"version": "1.3.1",
6+
"version": "1.4",
77

88
"commands": {
99
"redetect_fields": {

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"node-sass": "^5.0.0",
2525
"sass-loader": "^10.1.1",
2626
"style-loader": "^0.20.3",
27+
"svg-inline-loader": "^0.8.2",
2728
"ts-loader": "^8.0.14",
2829
"typescript": "^4.1.3",
2930
"typings-for-css-modules-loader": "^1.7.0",

src/Settings.ts

+6
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export interface ITheme {
1010
dropdownBorderWidth: number
1111
/** The width of the shadow of the credential dropdown list dropdown */
1212
dropdownShadowWidth: number
13+
/** The padding of an item in the credential dropdown list dropdown */
14+
dropdownItemPadding: number
15+
/** The color of the scrollbar in the credential dropdown list dropdown */
16+
dropdownScrollbarColor: string
1317
}
1418

1519
export interface ISettings
@@ -53,6 +57,8 @@ export const defaultSettings: ISettings =
5357
dropdownSelectedItemColorEnd: '#bac7ec',
5458
dropdownBorderWidth: 1,
5559
dropdownShadowWidth: 0,
60+
dropdownItemPadding: 3,
61+
dropdownScrollbarColor: '#5273d0'
5662
}
5763
}
5864

src/assets/copy_to_clipboard.svg

+7
Loading

src/classes/CredentialsDropdown.ts

+282
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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

Comments
 (0)