1
0
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:
antelle 2021-06-12 16:19:19 +02:00
parent 12dea11a9a
commit 3c2cab94d7
No known key found for this signature in database
GPG Key ID: 63C9777AAB7C563C
19 changed files with 461 additions and 441 deletions

View 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
}
}

View 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;
}
}

View File

@ -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) {

View 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 };

View File

@ -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;

View File

@ -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 };

View File

@ -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 };

View File

@ -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
});
};

View 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);
};

View 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
});
};

View 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
});
};

View File

@ -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 };
}

View File

@ -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>
);
};

View File

@ -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}>

View File

@ -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>

View 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>
);
};

View 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>
);
};

View File

@ -28,6 +28,8 @@
}
&-icon {
width: 1.6em;
position: relative;
top: .1em;
}
}
}

View File

@ -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~}}
&nbsp;<span class="muted-color">{{option.hint}}</span>
{{~/if~}}
</span>
</div>
{{/each}}
</div>