mirror of
https://github.com/keeweb/keeweb.git
synced 2024-06-28 07:50:55 +02:00
list context menu
This commit is contained in:
parent
12dea11a9a
commit
3c2cab94d7
80
app/scripts/comp/context-menu/context-menu-new.ts
Normal file
80
app/scripts/comp/context-menu/context-menu-new.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { ContextMenu, ContextMenuItem } from 'models/context-menu';
|
||||
import { StringFormat } from 'util/formatting/string-format';
|
||||
import { Locale } from 'util/locale';
|
||||
import { Features } from 'util/features';
|
||||
import { Shortcuts } from 'comp/browser/shortcuts';
|
||||
import { Position } from 'util/types';
|
||||
import { FileManager } from 'models/file-manager';
|
||||
import { Entry } from 'models/entry';
|
||||
import { File } from 'models/file';
|
||||
|
||||
export class ContextMenuNew {
|
||||
static show(pos: Position): void {
|
||||
const newEntryMenuItem = new ContextMenuItem(
|
||||
'entry',
|
||||
'key',
|
||||
StringFormat.capFirst(Locale.entry),
|
||||
() => this.newEntryClicked()
|
||||
);
|
||||
if (!Features.isMobile) {
|
||||
const shortcut = Shortcuts.presentShortcut('Alt+N');
|
||||
newEntryMenuItem.hint = `(${Locale.searchShiftClickOr} ${shortcut})`;
|
||||
}
|
||||
|
||||
const newGroupMenuItem = new ContextMenuItem(
|
||||
'group',
|
||||
'folder',
|
||||
StringFormat.capFirst(Locale.group),
|
||||
() => this.newGroupClicked()
|
||||
);
|
||||
|
||||
const templateMenuItems: ContextMenuItem[] = [];
|
||||
const hasMultipleFiles = FileManager.files.length > 1;
|
||||
for (const file of FileManager.files) {
|
||||
for (const entry of file.allEntryTemplates()) {
|
||||
if (!entry.title) {
|
||||
continue;
|
||||
}
|
||||
const id = `tmpl:${entry.id}`;
|
||||
templateMenuItems.push(
|
||||
new ContextMenuItem(
|
||||
id,
|
||||
entry.icon ?? 'sticky-note-o',
|
||||
hasMultipleFiles ? `${file.name} / ${entry.title}` : entry.title,
|
||||
() => this.newFromTemplateClicked(entry, file)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
||||
templateMenuItems.sort((x, y) => {
|
||||
return collator.compare(x.title, y.title);
|
||||
});
|
||||
templateMenuItems.push(
|
||||
new ContextMenuItem(
|
||||
'tmpl',
|
||||
'sticky-note-o',
|
||||
StringFormat.capFirst(Locale.template),
|
||||
() => this.newTemplateClicked()
|
||||
)
|
||||
);
|
||||
|
||||
ContextMenu.toggle('new', pos, [newEntryMenuItem, newGroupMenuItem, ...templateMenuItems]);
|
||||
}
|
||||
|
||||
static newEntryClicked(): void {
|
||||
// TODO
|
||||
}
|
||||
|
||||
static newGroupClicked(): void {
|
||||
// TODO
|
||||
}
|
||||
|
||||
static newTemplateClicked(): void {
|
||||
// TODO
|
||||
}
|
||||
|
||||
static newFromTemplateClicked(entry: Entry, file: File): void {
|
||||
// TODO
|
||||
}
|
||||
}
|
92
app/scripts/comp/context-menu/context-menu-sort.ts
Normal file
92
app/scripts/comp/context-menu/context-menu-sort.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
import { ContextMenu, ContextMenuItem } from 'models/context-menu';
|
||||
import { Position } from 'util/types';
|
||||
import { StringFormat } from 'util/formatting/string-format';
|
||||
import { Locale } from 'util/locale';
|
||||
import { QuerySort } from 'models/query';
|
||||
import { Workspace } from 'models/workspace';
|
||||
|
||||
const arrow = '→';
|
||||
|
||||
export class ContextMenuSort {
|
||||
static show(pos: Position): void {
|
||||
const items: ContextMenuItem[] = [
|
||||
new ContextMenuItem(
|
||||
QuerySort.TitleAsc,
|
||||
'sort-alpha-down',
|
||||
StringFormat.capFirst(Locale.title) + ' ' + Locale.searchAZ.with(arrow),
|
||||
() => this.itemClicked(QuerySort.TitleAsc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.TitleDesc,
|
||||
'sort-alpha-down-alt',
|
||||
StringFormat.capFirst(Locale.title) + ' ' + Locale.searchZA.with(arrow),
|
||||
() => this.itemClicked(QuerySort.TitleDesc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.WebsiteAsc,
|
||||
'sort-alpha-down',
|
||||
StringFormat.capFirst(Locale.website) + ' ' + Locale.searchAZ.with(arrow),
|
||||
() => this.itemClicked(QuerySort.WebsiteAsc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.WebsiteDesc,
|
||||
'sort-alpha-down-alt',
|
||||
StringFormat.capFirst(Locale.website) + ' ' + Locale.searchZA.with(arrow),
|
||||
() => this.itemClicked(QuerySort.WebsiteDesc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.UserAsc,
|
||||
'sort-alpha-down',
|
||||
StringFormat.capFirst(Locale.user) + ' ' + Locale.searchAZ.with(arrow),
|
||||
() => this.itemClicked(QuerySort.UserAsc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.UserDesc,
|
||||
'sort-alpha-down-alt',
|
||||
StringFormat.capFirst(Locale.user) + ' ' + Locale.searchZA.with(arrow),
|
||||
() => this.itemClicked(QuerySort.UserDesc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.CreatedAsc,
|
||||
'sort-numeric-down',
|
||||
StringFormat.capFirst(Locale.searchCreated) + ' ' + Locale.searchON.with(arrow),
|
||||
() => this.itemClicked(QuerySort.CreatedAsc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.CreatedDesc,
|
||||
'sort-numeric-down-alt',
|
||||
StringFormat.capFirst(Locale.searchCreated) + ' ' + Locale.searchNO.with(arrow),
|
||||
() => this.itemClicked(QuerySort.CreatedDesc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.UpdatedAsc,
|
||||
'sort-numeric-down',
|
||||
StringFormat.capFirst(Locale.searchUpdated) + ' ' + Locale.searchON.with(arrow),
|
||||
() => this.itemClicked(QuerySort.UpdatedAsc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.UpdatedDesc,
|
||||
'sort-numeric-down-alt',
|
||||
StringFormat.capFirst(Locale.searchUpdated) + ' ' + Locale.searchNO.with(arrow),
|
||||
() => this.itemClicked(QuerySort.UpdatedDesc)
|
||||
),
|
||||
new ContextMenuItem(
|
||||
QuerySort.AttachmentsDesc,
|
||||
'sort-amount-down',
|
||||
Locale.searchAttachments,
|
||||
() => this.itemClicked(QuerySort.AttachmentsDesc)
|
||||
),
|
||||
new ContextMenuItem(QuerySort.RankDesc, 'sort-amount-down', Locale.searchRank, () =>
|
||||
this.itemClicked(QuerySort.RankDesc)
|
||||
)
|
||||
];
|
||||
|
||||
const selectedItem = items.find((it) => it.id === Workspace.query.sort);
|
||||
|
||||
ContextMenu.toggle('sort', pos, items, selectedItem);
|
||||
}
|
||||
|
||||
private static itemClicked(sort: QuerySort) {
|
||||
Workspace.query.sort = sort;
|
||||
}
|
||||
}
|
|
@ -84,20 +84,6 @@ class AppModel {
|
|||
return matches.map((m) => m[0]);
|
||||
}
|
||||
|
||||
getEntryTemplates() {
|
||||
const entryTemplates = [];
|
||||
this.files.forEach((file) => {
|
||||
file.forEachEntryTemplate?.((entry) => {
|
||||
entryTemplates.push({ file, entry });
|
||||
});
|
||||
});
|
||||
return entryTemplates;
|
||||
}
|
||||
|
||||
canCreateEntries() {
|
||||
return this.files.some((f) => f.active && !f.readOnly);
|
||||
}
|
||||
|
||||
createNewEntry(args) {
|
||||
const sel = this.getFirstSelectedGroupForCreation();
|
||||
if (args?.template) {
|
||||
|
|
75
app/scripts/models/context-menu.ts
Normal file
75
app/scripts/models/context-menu.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { Model } from 'util/model';
|
||||
import { Callback, Position } from 'util/types';
|
||||
import { nextItem, prevItem } from 'util/fn';
|
||||
|
||||
export class ContextMenuItem extends Model {
|
||||
id: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
hint?: string;
|
||||
callback: Callback;
|
||||
|
||||
constructor(id: string, icon: string, title: string, callback: Callback) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.icon = icon;
|
||||
this.title = title;
|
||||
this.callback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
class ContextMenu extends Model {
|
||||
id = '';
|
||||
pos: Position = {};
|
||||
items: ContextMenuItem[] = [];
|
||||
selectedItem?: ContextMenuItem;
|
||||
|
||||
hide(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
toggle(
|
||||
id: string,
|
||||
pos: Position,
|
||||
items: ContextMenuItem[],
|
||||
selectedItem?: ContextMenuItem
|
||||
): void {
|
||||
this.batchSet(() => {
|
||||
const wasVisible = this.id === id;
|
||||
this.reset();
|
||||
if (!wasVisible) {
|
||||
this.pos = pos;
|
||||
this.items = items;
|
||||
this.selectedItem = selectedItem;
|
||||
this.id = id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectNext(): void {
|
||||
this.selectedItem = nextItem(this.items, (it) => it === this.selectedItem) ?? this.items[0];
|
||||
}
|
||||
|
||||
selectPrevious(): void {
|
||||
this.selectedItem =
|
||||
prevItem(this.items, (it) => it === this.selectedItem) ??
|
||||
this.items[this.items.length - 1];
|
||||
}
|
||||
|
||||
closeWithSelectedResult(): void {
|
||||
if (this.selectedItem) {
|
||||
this.closeWithResult(this.selectedItem);
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
closeWithResult(item: ContextMenuItem): void {
|
||||
item.callback();
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new ContextMenu();
|
||||
|
||||
export { instance as ContextMenu };
|
|
@ -14,11 +14,11 @@ class GeneratorState extends Model {
|
|||
password = '';
|
||||
derivedPreset?: PasswordGeneratorOptions;
|
||||
|
||||
hide() {
|
||||
hide(): void {
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
show(pos: Position) {
|
||||
show(pos: Position): void {
|
||||
this.batchSet(() => {
|
||||
this.reset();
|
||||
this.pos = pos;
|
||||
|
@ -26,7 +26,7 @@ class GeneratorState extends Model {
|
|||
});
|
||||
}
|
||||
|
||||
showWithPassword(pos: Position, password: kdbxweb.ProtectedValue) {
|
||||
showWithPassword(pos: Position, password: kdbxweb.ProtectedValue): void {
|
||||
this.batchSet(() => {
|
||||
this.reset();
|
||||
this.pos = pos;
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
import { Events } from 'framework/events';
|
||||
import { View } from 'framework/views/view';
|
||||
import { Keys } from 'const/keys';
|
||||
import template from 'templates/dropdown.hbs';
|
||||
|
||||
class DropdownView extends View {
|
||||
parent = 'body';
|
||||
modal = 'dropdown';
|
||||
|
||||
template = template;
|
||||
|
||||
events = {
|
||||
'click .dropdown__item': 'itemClick'
|
||||
};
|
||||
|
||||
constructor(model) {
|
||||
super(model);
|
||||
|
||||
Events.emit('dropdown-shown');
|
||||
this.bodyClick = this.bodyClick.bind(this);
|
||||
|
||||
this.listenTo(Events, 'show-context-menu', this.bodyClick);
|
||||
this.listenTo(Events, 'dropdown-shown', this.bodyClick);
|
||||
$('body').on('click contextmenu keydown', this.bodyClick);
|
||||
|
||||
this.onKey(Keys.DOM_VK_UP, this.upPressed, false, 'dropdown');
|
||||
this.onKey(Keys.DOM_VK_DOWN, this.downPressed, false, 'dropdown');
|
||||
this.onKey(Keys.DOM_VK_RETURN, this.enterPressed, false, 'dropdown');
|
||||
this.onKey(Keys.DOM_VK_ESCAPE, this.escPressed, false, 'dropdown');
|
||||
|
||||
this.once('remove', () => {
|
||||
$('body').off('click contextmenu keydown', this.bodyClick);
|
||||
});
|
||||
|
||||
this.selectedOption = model?.selectedOption;
|
||||
}
|
||||
|
||||
render(config) {
|
||||
this.options = config.options;
|
||||
super.render(config);
|
||||
const ownRect = this.$el[0].getBoundingClientRect();
|
||||
const bodyRect = document.body.getBoundingClientRect();
|
||||
let left = config.position.left || config.position.right - ownRect.right + ownRect.left;
|
||||
let top = config.position.top;
|
||||
if (left + ownRect.width > bodyRect.right) {
|
||||
left = Math.max(0, bodyRect.right - ownRect.width);
|
||||
}
|
||||
if (top + ownRect.height > bodyRect.bottom) {
|
||||
top = Math.max(0, bodyRect.bottom - ownRect.height);
|
||||
}
|
||||
this.$el.css({ top, left });
|
||||
if (typeof this.selectedOption === 'number') {
|
||||
this.renderSelectedOption();
|
||||
}
|
||||
}
|
||||
|
||||
bodyClick(e) {
|
||||
if (
|
||||
e &&
|
||||
[Keys.DOM_VK_UP, Keys.DOM_VK_DOWN, Keys.DOM_VK_RETURN, Keys.DOM_VK_ESCAPE].includes(
|
||||
e.which
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!this.removed) {
|
||||
this.emit('cancel');
|
||||
}
|
||||
}
|
||||
|
||||
itemClick(e) {
|
||||
e.stopPropagation();
|
||||
const el = $(e.target).closest('.dropdown__item');
|
||||
const selected = el.data('value');
|
||||
this.emit('select', { item: selected, el });
|
||||
}
|
||||
|
||||
upPressed(e) {
|
||||
e.preventDefault();
|
||||
if (!this.selectedOption) {
|
||||
this.selectedOption = this.options.length - 1;
|
||||
} else {
|
||||
this.selectedOption--;
|
||||
}
|
||||
this.renderSelectedOption();
|
||||
}
|
||||
|
||||
downPressed(e) {
|
||||
e.preventDefault();
|
||||
if (this.selectedOption === undefined || this.selectedOption === this.options.length - 1) {
|
||||
this.selectedOption = 0;
|
||||
} else {
|
||||
this.selectedOption++;
|
||||
}
|
||||
this.renderSelectedOption();
|
||||
}
|
||||
|
||||
renderSelectedOption() {
|
||||
this.$el.find('.dropdown__item').removeClass('dropdown__item--active');
|
||||
this.$el
|
||||
.find(`.dropdown__item:nth(${this.selectedOption})`)
|
||||
.addClass('dropdown__item--active');
|
||||
}
|
||||
|
||||
enterPressed() {
|
||||
if (!this.removed && this.selectedOption !== undefined) {
|
||||
const el = this.$el.find(`.dropdown__item:nth(${this.selectedOption})`);
|
||||
const selected = el.data('value');
|
||||
this.emit('select', { item: selected, el });
|
||||
}
|
||||
}
|
||||
|
||||
escPressed(e) {
|
||||
e.stopImmediatePropagation();
|
||||
if (!this.removed) {
|
||||
this.emit('cancel');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DropdownView };
|
|
@ -1,262 +0,0 @@
|
|||
import { View } from 'framework/views/view';
|
||||
import { Events } from 'framework/events';
|
||||
import { Shortcuts } from 'comp/app/shortcuts';
|
||||
import { KeyHandler } from 'comp/browser/key-handler';
|
||||
import { Keys } from 'const/keys';
|
||||
import { Comparators } from 'util/data/comparators';
|
||||
import { Features } from 'util/features';
|
||||
import { StringFormat } from 'util/formatting/string-format';
|
||||
import { Locale } from 'util/locale';
|
||||
import { DropdownView } from 'views/dropdown-view';
|
||||
import template from 'templates/list-search.hbs';
|
||||
|
||||
class ListSearchView extends View {
|
||||
parent = '.list__header';
|
||||
|
||||
template = template;
|
||||
|
||||
events = {
|
||||
'keydown .list__search-field': 'inputKeyDown',
|
||||
'input .list__search-field': 'inputChange',
|
||||
'focus .list__search-field': 'inputFocus',
|
||||
'click .list__search-btn-new': 'createOptionsClick',
|
||||
'click .list__search-btn-sort': 'sortOptionsClick',
|
||||
'click .list__search-icon-search': 'advancedSearchClick',
|
||||
'click .list__search-btn-menu': 'toggleMenu',
|
||||
'click .list__search-icon-clear': 'clickClear',
|
||||
'change .list__search-adv input[type=checkbox]': 'toggleAdvCheck'
|
||||
};
|
||||
|
||||
inputEl = null;
|
||||
sortOptions = null;
|
||||
sortIcons = null;
|
||||
createOptions = null;
|
||||
advancedSearchEnabled = false;
|
||||
advancedSearch = null;
|
||||
|
||||
constructor(model) {
|
||||
super(model);
|
||||
this.sortOptions = [
|
||||
{
|
||||
value: 'title',
|
||||
icon: 'sort-alpha-down',
|
||||
loc: () =>
|
||||
StringFormat.capFirst(Locale.title) + ' ' + this.addArrow(Locale.searchAZ)
|
||||
},
|
||||
{
|
||||
value: '-title',
|
||||
icon: 'sort-alpha-down-alt',
|
||||
loc: () =>
|
||||
StringFormat.capFirst(Locale.title) + ' ' + this.addArrow(Locale.searchZA)
|
||||
},
|
||||
{
|
||||
value: 'website',
|
||||
icon: 'sort-alpha-down',
|
||||
loc: () =>
|
||||
StringFormat.capFirst(Locale.website) + ' ' + this.addArrow(Locale.searchAZ)
|
||||
},
|
||||
{
|
||||
value: '-website',
|
||||
icon: 'sort-alpha-down-alt',
|
||||
loc: () =>
|
||||
StringFormat.capFirst(Locale.website) + ' ' + this.addArrow(Locale.searchZA)
|
||||
},
|
||||
{
|
||||
value: 'user',
|
||||
icon: 'sort-alpha-down',
|
||||
loc: () => StringFormat.capFirst(Locale.user) + ' ' + this.addArrow(Locale.searchAZ)
|
||||
},
|
||||
{
|
||||
value: '-user',
|
||||
icon: 'sort-alpha-down-alt',
|
||||
loc: () => StringFormat.capFirst(Locale.user) + ' ' + this.addArrow(Locale.searchZA)
|
||||
},
|
||||
{
|
||||
value: 'created',
|
||||
icon: 'sort-numeric-down',
|
||||
loc: () => Locale.searchCreated + ' ' + this.addArrow(Locale.searchON)
|
||||
},
|
||||
{
|
||||
value: '-created',
|
||||
icon: 'sort-numeric-down-alt',
|
||||
loc: () => Locale.searchCreated + ' ' + this.addArrow(Locale.searchNO)
|
||||
},
|
||||
{
|
||||
value: 'updated',
|
||||
icon: 'sort-numeric-down',
|
||||
loc: () => Locale.searchUpdated + ' ' + this.addArrow(Locale.searchON)
|
||||
},
|
||||
{
|
||||
value: '-updated',
|
||||
icon: 'sort-numeric-down-alt',
|
||||
loc: () => Locale.searchUpdated + ' ' + this.addArrow(Locale.searchNO)
|
||||
},
|
||||
{
|
||||
value: '-attachments',
|
||||
icon: 'sort-amount-down',
|
||||
loc: () => Locale.searchAttachments
|
||||
},
|
||||
{ value: '-rank', icon: 'sort-amount-down', loc: () => Locale.searchRank }
|
||||
];
|
||||
this.sortIcons = {};
|
||||
this.sortOptions.forEach((opt) => {
|
||||
this.sortIcons[opt.value] = opt.icon;
|
||||
});
|
||||
this.setLocale();
|
||||
this.onKey(Keys.DOM_VK_N, this.newKeyPress, KeyHandler.SHORTCUT_OPT);
|
||||
}
|
||||
|
||||
setLocale() {
|
||||
this.sortOptions.forEach((opt) => {
|
||||
opt.text = opt.loc();
|
||||
});
|
||||
this.createOptions = [
|
||||
{
|
||||
value: 'entry',
|
||||
icon: 'key',
|
||||
text: StringFormat.capFirst(Locale.entry),
|
||||
hint: Features.isMobile
|
||||
? null
|
||||
: `(${Locale.searchShiftClickOr} ${Shortcuts.altShortcutSymbol(true)})`
|
||||
},
|
||||
{ value: 'group', icon: 'folder', text: StringFormat.capFirst(Locale.group) }
|
||||
];
|
||||
if (this.el) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
newKeyPress(e) {
|
||||
if (!this.hidden) {
|
||||
e.preventDefault();
|
||||
this.hideSearchOptions();
|
||||
this.emit('create-entry');
|
||||
}
|
||||
}
|
||||
|
||||
createOptionsClick(e) {
|
||||
e.stopImmediatePropagation();
|
||||
if (e.shiftKey) {
|
||||
this.hideSearchOptions();
|
||||
this.emit('create-entry');
|
||||
return;
|
||||
}
|
||||
this.toggleCreateOptions();
|
||||
}
|
||||
|
||||
sortOptionsClick(e) {
|
||||
this.toggleSortOptions();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
hideSearchOptions() {
|
||||
if (this.views.searchDropdown) {
|
||||
this.views.searchDropdown.remove();
|
||||
this.views.searchDropdown = null;
|
||||
this.$el
|
||||
.find('.list__search-btn-sort,.list__search-btn-new')
|
||||
.removeClass('sel--active');
|
||||
}
|
||||
}
|
||||
|
||||
toggleSortOptions() {
|
||||
if (this.views.searchDropdown && this.views.searchDropdown.isSort) {
|
||||
this.hideSearchOptions();
|
||||
return;
|
||||
}
|
||||
this.hideSearchOptions();
|
||||
this.$el.find('.list__search-btn-sort').addClass('sel--active');
|
||||
const view = new DropdownView();
|
||||
view.isSort = true;
|
||||
this.listenTo(view, 'cancel', this.hideSearchOptions);
|
||||
this.listenTo(view, 'select', this.sortDropdownSelect);
|
||||
this.sortOptions.forEach(function (opt) {
|
||||
opt.active = this.model.sort === opt.value;
|
||||
}, this);
|
||||
view.render({
|
||||
position: {
|
||||
top: this.$el.find('.list__search-btn-sort')[0].getBoundingClientRect().bottom,
|
||||
right: this.$el[0].getBoundingClientRect().right + 1
|
||||
},
|
||||
options: this.sortOptions
|
||||
});
|
||||
this.views.searchDropdown = view;
|
||||
}
|
||||
|
||||
toggleCreateOptions() {
|
||||
if (this.views.searchDropdown && this.views.searchDropdown.isCreate) {
|
||||
this.hideSearchOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideSearchOptions();
|
||||
this.$el.find('.list__search-btn-new').addClass('sel--active');
|
||||
const view = new DropdownView();
|
||||
view.isCreate = true;
|
||||
this.listenTo(view, 'cancel', this.hideSearchOptions);
|
||||
this.listenTo(view, 'select', this.createDropdownSelect);
|
||||
view.render({
|
||||
position: {
|
||||
top: this.$el.find('.list__search-btn-new')[0].getBoundingClientRect().bottom,
|
||||
right: this.$el[0].getBoundingClientRect().right + 1
|
||||
},
|
||||
options: this.createOptions.concat(this.getCreateEntryTemplateOptions())
|
||||
});
|
||||
this.views.searchDropdown = view;
|
||||
}
|
||||
|
||||
getCreateEntryTemplateOptions() {
|
||||
const entryTemplates = this.model.getEntryTemplates();
|
||||
const hasMultipleFiles = this.model.files.length > 1;
|
||||
this.entryTemplates = {};
|
||||
const options = [];
|
||||
entryTemplates.forEach((tmpl) => {
|
||||
const id = 'tmpl:' + tmpl.entry.id;
|
||||
options.push({
|
||||
value: id,
|
||||
icon: tmpl.entry.icon,
|
||||
text: hasMultipleFiles
|
||||
? tmpl.file.name + ' / ' + tmpl.entry.title
|
||||
: tmpl.entry.title
|
||||
});
|
||||
this.entryTemplates[id] = tmpl;
|
||||
});
|
||||
options.sort(Comparators.stringComparator('text', true));
|
||||
options.push({
|
||||
value: 'tmpl',
|
||||
icon: 'sticky-note-o',
|
||||
text: StringFormat.capFirst(Locale.template)
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
sortDropdownSelect(e) {
|
||||
this.hideSearchOptions();
|
||||
Events.emit('set-sort', e.item);
|
||||
}
|
||||
|
||||
createDropdownSelect(e) {
|
||||
this.hideSearchOptions();
|
||||
switch (e.item) {
|
||||
case 'entry':
|
||||
this.emit('create-entry');
|
||||
break;
|
||||
case 'group':
|
||||
this.emit('create-group');
|
||||
break;
|
||||
case 'tmpl':
|
||||
this.emit('create-template');
|
||||
break;
|
||||
default:
|
||||
if (this.entryTemplates[e.item]) {
|
||||
this.emit('create-entry', { template: this.entryTemplates[e.item] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addArrow(str) {
|
||||
return str.replace('{}', '→');
|
||||
}
|
||||
}
|
||||
|
||||
export { ListSearchView };
|
|
@ -1,14 +1,28 @@
|
|||
import { FunctionComponent, h } from 'preact';
|
||||
import { ListSearchView } from 'views/list/list-search-view';
|
||||
import { useModelField } from 'util/ui/hooks';
|
||||
import { useKey, useModelField } from 'util/ui/hooks';
|
||||
import { Workspace } from 'models/workspace';
|
||||
import { PropertiesOfType } from 'util/types';
|
||||
import { Position, PropertiesOfType } from 'util/types';
|
||||
import { AdvancedFilter } from 'models/filter';
|
||||
import { FileManager } from 'models/file-manager';
|
||||
import { ContextMenuNew } from 'comp/context-menu/context-menu-new';
|
||||
import { ContextMenuSort } from 'comp/context-menu/context-menu-sort';
|
||||
import { ContextMenu } from 'models/context-menu';
|
||||
import { Keys } from 'const/keys';
|
||||
import { KeyHandler } from 'comp/browser/key-handler';
|
||||
|
||||
export const ListSearch: FunctionComponent = () => {
|
||||
const adv = useModelField(Workspace.query.filter, 'advanced');
|
||||
|
||||
useKey(
|
||||
Keys.DOM_VK_N,
|
||||
() => {
|
||||
ContextMenu.hide();
|
||||
ContextMenuNew.newEntryClicked();
|
||||
},
|
||||
KeyHandler.SHORTCUT_OPT
|
||||
);
|
||||
|
||||
const toggleAdvClicked = () => {
|
||||
Workspace.query.filter.advanced = adv ? undefined : Workspace.lastAdvancedFilter;
|
||||
};
|
||||
|
@ -31,6 +45,17 @@ export const ListSearch: FunctionComponent = () => {
|
|||
Workspace.query.filter.text = undefined;
|
||||
};
|
||||
|
||||
const newClicked = (shift: boolean, pos: Position) => {
|
||||
if (shift) {
|
||||
ContextMenuNew.newEntryClicked();
|
||||
ContextMenu.hide();
|
||||
} else {
|
||||
ContextMenuNew.show(pos);
|
||||
}
|
||||
};
|
||||
|
||||
const sortClicked = (pos: Position) => ContextMenuSort.show(pos);
|
||||
|
||||
return h(ListSearchView, {
|
||||
canCreate: FileManager.hasWritableFiles(),
|
||||
showAdvanced: !!adv,
|
||||
|
@ -40,6 +65,8 @@ export const ListSearch: FunctionComponent = () => {
|
|||
toggleAdvClicked,
|
||||
advChecked,
|
||||
searchChanged,
|
||||
clearClicked
|
||||
clearClicked,
|
||||
newClicked,
|
||||
sortClicked
|
||||
});
|
||||
};
|
||||
|
|
14
app/scripts/ui/menu/app-context-menu-container.ts
Normal file
14
app/scripts/ui/menu/app-context-menu-container.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { FunctionComponent, h } from 'preact';
|
||||
import { AppContextMenu } from './app-context-menu';
|
||||
import { useModelWatcher } from 'util/ui/hooks';
|
||||
import { ContextMenu } from 'models/context-menu';
|
||||
|
||||
export const AppContextMenuContainer: FunctionComponent = () => {
|
||||
useModelWatcher(ContextMenu);
|
||||
|
||||
if (!ContextMenu.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return h(AppContextMenu, null);
|
||||
};
|
21
app/scripts/ui/menu/app-context-menu-item.ts
Normal file
21
app/scripts/ui/menu/app-context-menu-item.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { FunctionComponent, h } from 'preact';
|
||||
import { AppContextMenuItemView } from 'views/menu/app-context-menu-item-view';
|
||||
import { ContextMenu, ContextMenuItem } from 'models/context-menu';
|
||||
|
||||
export const AppContextMenuItem: FunctionComponent<{ item: ContextMenuItem; active: boolean }> = ({
|
||||
item,
|
||||
active
|
||||
}) => {
|
||||
const onClick = () => {
|
||||
ContextMenu.closeWithResult(item);
|
||||
};
|
||||
|
||||
return h(AppContextMenuItemView, {
|
||||
active,
|
||||
icon: item.icon,
|
||||
title: item.title,
|
||||
hint: item.hint,
|
||||
|
||||
onClick
|
||||
});
|
||||
};
|
26
app/scripts/ui/menu/app-context-menu.ts
Normal file
26
app/scripts/ui/menu/app-context-menu.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { FunctionComponent, h } from 'preact';
|
||||
import { AppContextMenuView } from 'views/menu/app-context-menu-view';
|
||||
import { useKey, useModal, useModelWatcher } from 'util/ui/hooks';
|
||||
import { ContextMenu } from 'models/context-menu';
|
||||
import { Keys } from 'const/keys';
|
||||
|
||||
export const AppContextMenu: FunctionComponent = () => {
|
||||
useModal('dropdown');
|
||||
useModelWatcher(ContextMenu);
|
||||
|
||||
useKey(Keys.DOM_VK_UP, () => ContextMenu.selectPrevious(), undefined, 'dropdown');
|
||||
useKey(Keys.DOM_VK_DOWN, () => ContextMenu.selectNext(), undefined, 'dropdown');
|
||||
useKey(Keys.DOM_VK_ESCAPE, () => ContextMenu.hide(), undefined, 'dropdown');
|
||||
useKey(Keys.DOM_VK_ENTER, () => ContextMenu.closeWithSelectedResult(), undefined, 'dropdown');
|
||||
useKey(Keys.DOM_VK_RETURN, () => ContextMenu.closeWithSelectedResult(), undefined, 'dropdown');
|
||||
|
||||
const bodyClicked = () => ContextMenu.hide();
|
||||
|
||||
return h(AppContextMenuView, {
|
||||
pos: ContextMenu.pos,
|
||||
items: ContextMenu.items,
|
||||
selectedItem: ContextMenu.selectedItem,
|
||||
|
||||
bodyClicked
|
||||
});
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import { AppSettings, AppSettingsFieldName } from 'models/app-settings';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Callback, NonFunctionPropertyNames } from 'util/types';
|
||||
import { Ref, useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
||||
import { Callback, NonFunctionPropertyNames, Position } from 'util/types';
|
||||
import { ListenerSignature, Model } from 'util/model';
|
||||
import { KeyHandler } from 'comp/browser/key-handler';
|
||||
import { Keys } from 'const/keys';
|
||||
|
@ -73,6 +73,32 @@ export function useModal(name: string): void {
|
|||
export function useBodyClick(onClick: Callback): void {
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', onClick);
|
||||
return () => document.removeEventListener('click', onClick);
|
||||
document.addEventListener('contextmenu', onClick);
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick);
|
||||
document.removeEventListener('contextmenu', onClick);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function usePositionable(
|
||||
pos: Position,
|
||||
el: Ref<HTMLDivElement>
|
||||
): { top: number; left: number } {
|
||||
const [top, setTop] = useState(pos.top ?? -100000);
|
||||
const [left, setLeft] = useState(pos.left ?? -100000);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if ((pos.top === undefined || pos.left === undefined) && el.current) {
|
||||
const rect = el.current.getBoundingClientRect();
|
||||
if (pos.top === undefined && pos.bottom) {
|
||||
setTop(pos.bottom - rect.height);
|
||||
}
|
||||
if (pos.left === undefined && pos.right) {
|
||||
setLeft(pos.right - rect.width);
|
||||
}
|
||||
}
|
||||
}, [pos]);
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { List } from 'ui/list/list';
|
|||
import { Settings } from 'ui/settings/settings';
|
||||
import { DragHandle } from 'views/components/drag-handle';
|
||||
import { Generator } from 'ui/generator';
|
||||
import { AppContextMenuContainer } from 'ui/menu/app-context-menu-container';
|
||||
import { useRef } from 'preact/hooks';
|
||||
import { classes } from 'util/ui/classes';
|
||||
|
||||
|
@ -99,6 +100,7 @@ export const AppView: FunctionComponent<{
|
|||
<Footer />
|
||||
</div>
|
||||
<Generator />
|
||||
<AppContextMenuContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,8 +6,8 @@ import {
|
|||
} from 'util/generators/password-generator';
|
||||
import { Position, PropertiesOfType } from 'util/types';
|
||||
import { classes } from 'util/ui/classes';
|
||||
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useBodyClick } from 'util/ui/hooks';
|
||||
import { useRef } from 'preact/hooks';
|
||||
import { useBodyClick, usePositionable } from 'util/ui/hooks';
|
||||
import { withoutPropagation } from 'util/ui/events';
|
||||
|
||||
export const GeneratorView: FunctionComponent<{
|
||||
|
@ -55,23 +55,10 @@ export const GeneratorView: FunctionComponent<{
|
|||
|
||||
useBodyClick(bodyClicked);
|
||||
|
||||
const [top, setTop] = useState(pos.top);
|
||||
const [left, setLeft] = useState(pos.left);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!top || !left) {
|
||||
const rect = el.current.getBoundingClientRect();
|
||||
if (!top && pos.bottom) {
|
||||
setTop(pos.bottom - rect.height);
|
||||
}
|
||||
if (!left && pos.right) {
|
||||
setLeft(pos.right - rect.width);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
const position = usePositionable(pos, el);
|
||||
|
||||
return (
|
||||
<div class="gen" style={{ top, left }} onClick={withoutPropagation()} ref={el}>
|
||||
<div class="gen" style={position} onClick={withoutPropagation()} ref={el}>
|
||||
<div>
|
||||
{Locale.genLen}: <span class="gen__length-range-val">{opt.length}</span>
|
||||
<i class="fa fa-sync-alt gen__btn-refresh gen__top-btn" onClick={refreshClicked}>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { FunctionComponent } from 'preact';
|
|||
import { StringFormat } from 'util/formatting/string-format';
|
||||
import { Locale } from 'util/locale';
|
||||
import { AdvancedFilter } from 'models/filter';
|
||||
import { PropertiesOfType } from 'util/types';
|
||||
import { Position, PropertiesOfType } from 'util/types';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { useEvent, useKey } from 'util/ui/hooks';
|
||||
import { Keys } from 'const/keys';
|
||||
|
@ -20,6 +20,8 @@ export const ListSearchView: FunctionComponent<{
|
|||
advChecked: (field: PropertiesOfType<AdvancedFilter, boolean | undefined>) => void;
|
||||
searchChanged: (text: string) => void;
|
||||
clearClicked: () => void;
|
||||
newClicked: (shift: boolean, pos: Position) => void;
|
||||
sortClicked: (pos: Position) => void;
|
||||
}> = ({
|
||||
canCreate,
|
||||
showAdvanced,
|
||||
|
@ -29,9 +31,12 @@ export const ListSearchView: FunctionComponent<{
|
|||
toggleAdvClicked,
|
||||
advChecked,
|
||||
searchChanged,
|
||||
clearClicked
|
||||
clearClicked,
|
||||
newClicked,
|
||||
sortClicked
|
||||
}) => {
|
||||
const input = useRef<HTMLInputElement>();
|
||||
const el = useRef<HTMLDivElement>();
|
||||
|
||||
useKey(
|
||||
Keys.DOM_VK_F,
|
||||
|
@ -81,8 +86,25 @@ export const ListSearchView: FunctionComponent<{
|
|||
e.preventDefault();
|
||||
};
|
||||
|
||||
const getContextMenuPosition = () => {
|
||||
return {
|
||||
right: el.current.getBoundingClientRect().right + 1,
|
||||
top: input.current.getBoundingClientRect().bottom
|
||||
};
|
||||
};
|
||||
|
||||
const newClickedInternal = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
newClicked(e.shiftKey, getContextMenuPosition());
|
||||
};
|
||||
|
||||
const sortClickedInternal = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
sortClicked(getContextMenuPosition());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="list__search">
|
||||
<div class="list__search" ref={el}>
|
||||
<div class="list__search-header">
|
||||
<div class="list__search-btn-menu">
|
||||
<i class="fa fa-bars" />
|
||||
|
@ -113,12 +135,12 @@ export const ListSearchView: FunctionComponent<{
|
|||
</div>
|
||||
</div>
|
||||
{canCreate ? (
|
||||
<div class="list__search-btn-new">
|
||||
<div class="list__search-btn-new" onClick={newClickedInternal}>
|
||||
<kw-tip text={Locale.searchAddNew} />
|
||||
<i class="fa fa-plus" />
|
||||
</div>
|
||||
) : null}
|
||||
<div class="list__search-btn-sort">
|
||||
<div class="list__search-btn-sort" onClick={sortClickedInternal}>
|
||||
<kw-tip text={Locale.searchSort} />
|
||||
<i class="fa fa-sort-alpha-down" />
|
||||
</div>
|
||||
|
|
28
app/scripts/views/menu/app-context-menu-item-view.tsx
Normal file
28
app/scripts/views/menu/app-context-menu-item-view.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { FunctionComponent } from 'preact';
|
||||
import { classes } from 'util/ui/classes';
|
||||
import { withoutPropagation } from 'util/ui/events';
|
||||
|
||||
export const AppContextMenuItemView: FunctionComponent<{
|
||||
active: boolean;
|
||||
icon: string;
|
||||
title: string;
|
||||
hint?: string;
|
||||
|
||||
onClick: () => void;
|
||||
}> = ({ active, icon, title, hint, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
class={classes({
|
||||
'dropdown__item': true,
|
||||
'dropdown__item--active': active
|
||||
})}
|
||||
onClick={withoutPropagation(onClick)}
|
||||
>
|
||||
<i class={`fa fa-${icon} dropdown__item-icon`} />{' '}
|
||||
<span class="dropdown__item-text">
|
||||
{title}
|
||||
{hint ? <span class="muted-color"> {hint}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
28
app/scripts/views/menu/app-context-menu-view.tsx
Normal file
28
app/scripts/views/menu/app-context-menu-view.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { FunctionComponent } from 'preact';
|
||||
import { AppContextMenuItem } from 'ui/menu/app-context-menu-item';
|
||||
import { ContextMenuItem } from 'models/context-menu';
|
||||
import { useRef } from 'preact/hooks';
|
||||
import { withoutPropagation } from 'util/ui/events';
|
||||
import { useBodyClick, usePositionable } from 'util/ui/hooks';
|
||||
import { Position } from 'util/types';
|
||||
|
||||
export const AppContextMenuView: FunctionComponent<{
|
||||
pos: Position;
|
||||
items: ContextMenuItem[];
|
||||
selectedItem: ContextMenuItem | undefined;
|
||||
|
||||
bodyClicked: () => void;
|
||||
}> = ({ pos, items, selectedItem, bodyClicked }) => {
|
||||
const el = useRef<HTMLDivElement>();
|
||||
|
||||
useBodyClick(bodyClicked);
|
||||
const position = usePositionable(pos, el);
|
||||
|
||||
return (
|
||||
<div class="dropdown" style={position} onClick={withoutPropagation()} ref={el}>
|
||||
{items.map((item) => (
|
||||
<AppContextMenuItem item={item} active={item === selectedItem} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -28,6 +28,8 @@
|
|||
}
|
||||
&-icon {
|
||||
width: 1.6em;
|
||||
position: relative;
|
||||
top: .1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
<div class="dropdown">
|
||||
{{#each options as |option|}}
|
||||
<div class="dropdown__item {{#if option.active}}dropdown__item--active{{/if}}" data-value="{{option.value}}">
|
||||
<i class="fa fa-{{option.icon}} dropdown__item-icon"></i>
|
||||
<span class="dropdown__item-text">
|
||||
{{option.text}}
|
||||
{{~#if option.hint~}}
|
||||
<span class="muted-color">{{option.hint}}</span>
|
||||
{{~/if~}}
|
||||
</span>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
Loading…
Reference in New Issue
Block a user