mirror of
https://github.com/keeweb/keeweb.git
synced 2024-06-29 08:00:53 +02:00
scrolling
This commit is contained in:
parent
3a4b1f56d9
commit
5140d729e2
|
@ -31,9 +31,11 @@ export class Query extends Model<QueryEvents> {
|
|||
readonly filter = new Filter();
|
||||
sort: QuerySort = QuerySort.TitleAsc;
|
||||
|
||||
private _preparingFilter?: boolean;
|
||||
private _entries?: Entry[];
|
||||
private _groups?: Group[];
|
||||
private _preparingFilter?: boolean;
|
||||
private readonly _entryIx = new Map<string, number>();
|
||||
private readonly _groupIx = new Map<string, number>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
@ -56,6 +58,24 @@ export class Query extends Model<QueryEvents> {
|
|||
return this._groups || [];
|
||||
}
|
||||
|
||||
hasItem(id: string): boolean {
|
||||
if (!this._entries) {
|
||||
this.runQuery();
|
||||
}
|
||||
return this._entryIx.has(id) || this._groupIx.has(id);
|
||||
}
|
||||
|
||||
itemIndex(id: string): number | undefined {
|
||||
if (!this._entries) {
|
||||
this.runQuery();
|
||||
}
|
||||
const entryIx = this._entryIx.get(id);
|
||||
if (entryIx !== undefined) {
|
||||
return this.groups.length + entryIx;
|
||||
}
|
||||
return this._groupIx.get(id);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.filter.reset();
|
||||
}
|
||||
|
@ -63,6 +83,8 @@ export class Query extends Model<QueryEvents> {
|
|||
updateResults(): void {
|
||||
this._entries = undefined;
|
||||
this._groups = undefined;
|
||||
this._entryIx.clear();
|
||||
this._groupIx.clear();
|
||||
this.emit('results-updated');
|
||||
}
|
||||
|
||||
|
@ -118,6 +140,9 @@ export class Query extends Model<QueryEvents> {
|
|||
|
||||
this._entries = entries;
|
||||
this._groups = groups;
|
||||
|
||||
entries.forEach((e, ix) => this._entryIx.set(e.id, ix));
|
||||
groups.forEach((g, ix) => this._groupIx.set(g.id, ix));
|
||||
}
|
||||
|
||||
private static addTrashGroups(groups: Group[]) {
|
||||
|
|
|
@ -285,11 +285,7 @@ class Workspace extends Model {
|
|||
}
|
||||
|
||||
private queryResultsUpdated() {
|
||||
if (
|
||||
!this.activeItemId ||
|
||||
!this.query.groups.some((item) => item.id === this.activeItemId) ||
|
||||
!this.query.entries.some((item) => item.id === this.activeItemId)
|
||||
) {
|
||||
if (!this.activeItemId || !this.query.hasItem(this.activeItemId)) {
|
||||
this.activeItemId = this.query.groups[0]?.id ?? this.query.entries[0]?.id;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
import { View, DefaultTemplateOptions } from 'framework/views/view';
|
||||
import { View } from 'framework/views/view';
|
||||
import { Events } from 'framework/events';
|
||||
import { DragDropInfo } from 'comp/app/drag-drop-info';
|
||||
import { Alerts } from 'comp/ui/alerts';
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import { EntryPresenter } from 'presenters/entry-presenter';
|
||||
import { StringFormat } from 'util/formatting/string-format';
|
||||
import { Locale } from 'util/locale';
|
||||
import { Resizable } from 'framework/views/resizable';
|
||||
import { Scrollable } from 'framework/views/scrollable';
|
||||
import { DropdownView } from 'views/dropdown-view';
|
||||
|
||||
class ListView extends View {
|
||||
|
@ -21,162 +18,6 @@ class ListView extends View {
|
|||
{ val: 'fileName', name: 'file', enabled: false }
|
||||
];
|
||||
|
||||
render() {
|
||||
if (!this.itemsEl) {
|
||||
super.render();
|
||||
this.itemsEl = this.$el.find('.list__items>.scroller');
|
||||
this.itemsEl.on('scroll', () => this.renderVisibleItems());
|
||||
this.views.search.render();
|
||||
this.setTableView();
|
||||
|
||||
this.createScroll({
|
||||
root: this.$el.find('.list__items')[0],
|
||||
scroller: this.$el.find('.scroller')[0],
|
||||
bar: this.$el.find('.scroller__bar')[0]
|
||||
});
|
||||
}
|
||||
if (this.items.length) {
|
||||
const itemsTemplate = this.getItemsTemplate();
|
||||
const noColor = AppSettingsModel.colorfulIcons ? '' : 'grayscale';
|
||||
|
||||
const presenter = new EntryPresenter(
|
||||
this.getDescField(),
|
||||
noColor,
|
||||
this.model.activeEntryId
|
||||
);
|
||||
const columns = {};
|
||||
this.tableColumns.forEach((col) => {
|
||||
if (col.enabled) {
|
||||
columns[col.val] = true;
|
||||
}
|
||||
});
|
||||
presenter.columns = columns;
|
||||
this.presenter = presenter;
|
||||
|
||||
presenter.present(this.items[0]);
|
||||
const itemTemplate = this.getItemTemplate();
|
||||
const itemsHtml = itemTemplate(presenter, DefaultTemplateOptions);
|
||||
presenter.reset();
|
||||
|
||||
const html = itemsTemplate(
|
||||
{ itemsHtml, columns: this.tableColumns },
|
||||
DefaultTemplateOptions
|
||||
);
|
||||
this.itemsEl.html(html);
|
||||
this.itemsContainerEl = this.itemsEl.find('.list__items-container:first')[0];
|
||||
|
||||
const firstListItem = this.itemsContainerEl.firstElementChild;
|
||||
this.itemHeight = firstListItem.getBoundingClientRect().height;
|
||||
|
||||
this.renderedItems = new Map([[0, firstListItem]]);
|
||||
|
||||
const totalHeight = this.itemHeight * this.items.length;
|
||||
this.itemsContainerEl.style.minHeight = totalHeight + 'px';
|
||||
|
||||
this.renderVisibleItems();
|
||||
} else {
|
||||
this.itemsEl.html(this.emptyTemplate({}, DefaultTemplateOptions));
|
||||
}
|
||||
this.pageResized();
|
||||
}
|
||||
|
||||
renderVisibleItems() {
|
||||
if (!this.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollEl = this.itemsEl[0];
|
||||
const rect = scrollEl.getBoundingClientRect();
|
||||
|
||||
const pxTop = scrollEl.scrollTop;
|
||||
const pxHeight = rect.height;
|
||||
const itemHeight = this.itemHeight;
|
||||
const renderedItems = this.renderedItems;
|
||||
|
||||
let firstIx = Math.max(0, Math.floor(pxTop / itemHeight));
|
||||
let lastIx = Math.min(this.items.length - 1, Math.ceil((pxTop + pxHeight) / itemHeight));
|
||||
|
||||
const visibleCount = lastIx - firstIx;
|
||||
firstIx = Math.max(0, firstIx - visibleCount);
|
||||
lastIx = Math.min(this.items.length - 1, lastIx + visibleCount);
|
||||
|
||||
const itemTemplate = this.getItemTemplate();
|
||||
const presenter = this.presenter;
|
||||
|
||||
let itemsHtml = '';
|
||||
const renderedIndices = [];
|
||||
|
||||
for (let ix = firstIx; ix <= lastIx; ix++) {
|
||||
const item = this.items[ix];
|
||||
if (renderedItems.has(ix)) {
|
||||
continue;
|
||||
}
|
||||
presenter.present(item);
|
||||
itemsHtml += itemTemplate(presenter, DefaultTemplateOptions);
|
||||
renderedIndices.push(ix);
|
||||
}
|
||||
presenter.reset();
|
||||
|
||||
const tempEl = document.createElement('div');
|
||||
tempEl.innerHTML = itemsHtml;
|
||||
const renderedElements = [...tempEl.children];
|
||||
|
||||
for (let i = 0; i < renderedElements.length; i++) {
|
||||
const el = renderedElements[i];
|
||||
const ix = renderedIndices[i];
|
||||
this.itemsContainerEl.append(el);
|
||||
el.style.top = ix * itemHeight + 'px';
|
||||
renderedItems.set(ix, el);
|
||||
}
|
||||
|
||||
const maxRenderedItems = visibleCount * 5;
|
||||
|
||||
if (renderedItems.size > maxRenderedItems) {
|
||||
for (const [ix, el] of this.renderedItems) {
|
||||
if (ix < firstIx || ix > lastIx) {
|
||||
el.remove();
|
||||
renderedItems.delete(ix);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureItemRendered(ix) {
|
||||
if (this.renderedItems.has(ix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = this.items[ix];
|
||||
const itemTemplate = this.getItemTemplate();
|
||||
|
||||
this.presenter.present(item);
|
||||
const itemHtml = itemTemplate(this.presenter, DefaultTemplateOptions);
|
||||
this.presenter.reset();
|
||||
|
||||
const tempEl = document.createElement('div');
|
||||
tempEl.innerHTML = itemHtml;
|
||||
|
||||
const [el] = tempEl.children;
|
||||
|
||||
this.itemsContainerEl.append(el);
|
||||
el.style.top = ix * this.itemHeight + 'px';
|
||||
|
||||
this.renderedItems.set(ix, el);
|
||||
}
|
||||
|
||||
click(e) {
|
||||
const listItemEl = e.target.closest('.list__item');
|
||||
if (!listItemEl) {
|
||||
return;
|
||||
}
|
||||
const id = listItemEl.id;
|
||||
const item = this.items.get(id);
|
||||
if (!item.active) {
|
||||
this.selectItem(item);
|
||||
}
|
||||
Events.emit('toggle-details', true);
|
||||
}
|
||||
|
||||
createEntry(arg) {
|
||||
const newEntry = this.model.createNewEntry(arg);
|
||||
this.items.unshift(newEntry);
|
||||
|
@ -213,27 +54,6 @@ class ListView extends View {
|
|||
this.selectItem(templateEntry);
|
||||
}
|
||||
|
||||
selectItem(item) {
|
||||
this.presenter.activeEntryId = item.id;
|
||||
this.model.activeEntryId = item.id;
|
||||
|
||||
const ix = this.items.indexOf(item);
|
||||
this.ensureItemRendered(ix);
|
||||
|
||||
Events.emit('entry-selected', item);
|
||||
this.itemsEl.find('.list__item--active').removeClass('list__item--active');
|
||||
const itemEl = document.getElementById(item.id);
|
||||
itemEl.classList.add('list__item--active');
|
||||
const listEl = this.itemsEl[0];
|
||||
const itemRect = itemEl.getBoundingClientRect();
|
||||
const listRect = listEl.getBoundingClientRect();
|
||||
if (itemRect.top < listRect.top) {
|
||||
listEl.scrollTop += itemRect.top - listRect.top;
|
||||
} else if (itemRect.bottom > listRect.bottom) {
|
||||
listEl.scrollTop += itemRect.bottom - listRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
setTableView() {
|
||||
const isTable = this.model.settings.tableView;
|
||||
this.dragView.setCoord(isTable ? 'y' : 'x');
|
||||
|
@ -312,7 +132,4 @@ class ListView extends View {
|
|||
}
|
||||
}
|
||||
|
||||
Object.assign(ListView.prototype, Resizable);
|
||||
Object.assign(ListView.prototype, Scrollable);
|
||||
|
||||
export { ListView };
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Workspace } from 'models/workspace';
|
|||
import { QuerySort } from 'models/query';
|
||||
import { DateFormat } from 'util/formatting/date-format';
|
||||
import { Locale } from 'util/locale';
|
||||
import { AppSettings } from 'models/app-settings';
|
||||
|
||||
const DefaultIcon = 'key';
|
||||
|
||||
|
@ -17,14 +18,20 @@ export const ListEntryShort: FunctionComponent<{ entry: Entry; active: boolean }
|
|||
Workspace.activeItemId = entry.id;
|
||||
};
|
||||
|
||||
let color = entry.color ?? '';
|
||||
if (!color && entry.customIcon && !AppSettings.colorfulIcons) {
|
||||
color = 'grayscale';
|
||||
}
|
||||
|
||||
return h(ListItemShortView, {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
description: getDescription(entry, sort),
|
||||
active,
|
||||
expired: entry.expired,
|
||||
icon: entry.icon ?? entry.customIcon ?? DefaultIcon,
|
||||
isCustomIcon: !!entry.customIcon,
|
||||
color: entry.color,
|
||||
color,
|
||||
|
||||
itemClicked
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { FunctionComponent, h } from 'preact';
|
||||
import { ListView } from 'views/list/list-view';
|
||||
import { Workspace } from 'models/workspace';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useKey, useModelField } from 'util/ui/hooks';
|
||||
import { Keys } from 'const/keys';
|
||||
import { nextItem, prevItem } from 'util/fn';
|
||||
|
@ -66,25 +66,39 @@ export const List: FunctionComponent = () => {
|
|||
}, [AppSettings.tableView]);
|
||||
const visibleItemsCount = Math.ceil(window.innerHeight / itemHeight);
|
||||
const scrollBufferSizeInItems = Math.max(4, Math.ceil(visibleItemsCount / 2));
|
||||
const firstVisibleItem = Math.floor(scrollTop / itemHeight);
|
||||
const lastVisibleItem = firstVisibleItem + visibleItemsCount;
|
||||
const firstItem = Math.max(0, firstVisibleItem - scrollBufferSizeInItems);
|
||||
const lastItem = Math.min(itemsCount, lastVisibleItem + scrollBufferSizeInItems);
|
||||
|
||||
const firstGroup = firstItem < groups.length ? firstItem : -1;
|
||||
const lastGroup = lastItem < groups.length ? lastItem : groups.length - 1;
|
||||
const firstEntry = lastItem < groups.length ? -1 : Math.max(0, firstItem - groups.length);
|
||||
const lastEntry = lastItem - groups.length;
|
||||
let firstVisibleItem = Math.floor(scrollTop / itemHeight);
|
||||
let lastVisibleItem = firstVisibleItem + visibleItemsCount;
|
||||
|
||||
const firstItemOffset = firstItem * itemHeight;
|
||||
const activeItemIndex = activeItemId ? Workspace.query.itemIndex(activeItemId) : undefined;
|
||||
const lastActiveItemIndex = useRef<number | undefined>();
|
||||
if (lastActiveItemIndex.current !== activeItemIndex) {
|
||||
lastActiveItemIndex.current = activeItemIndex;
|
||||
if (activeItemIndex && activeItemIndex < firstVisibleItem) {
|
||||
firstVisibleItem = activeItemIndex;
|
||||
lastVisibleItem = firstVisibleItem + visibleItemsCount;
|
||||
} else if (activeItemIndex && activeItemIndex > lastVisibleItem) {
|
||||
firstVisibleItem = activeItemIndex - visibleItemsCount;
|
||||
lastVisibleItem = activeItemIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const firstRenderedItem = Math.max(0, firstVisibleItem - scrollBufferSizeInItems);
|
||||
const lastRenderedItem = Math.min(itemsCount, lastVisibleItem + scrollBufferSizeInItems);
|
||||
|
||||
const firstGroup = firstRenderedItem < groups.length ? firstRenderedItem : -1;
|
||||
const lastGroup = lastRenderedItem < groups.length ? lastRenderedItem : groups.length - 1;
|
||||
const firstEntry =
|
||||
lastRenderedItem < groups.length ? -1 : Math.max(0, firstRenderedItem - groups.length);
|
||||
const lastEntry = lastRenderedItem - groups.length;
|
||||
|
||||
const firstItemOffset = firstRenderedItem * itemHeight;
|
||||
const totalHeight = itemsCount * itemHeight;
|
||||
|
||||
const onScroll = (e: Event) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
if (e.target instanceof HTMLElement) {
|
||||
setScrollTop(scrollTop);
|
||||
}
|
||||
setScrollTop(target.scrollTop);
|
||||
};
|
||||
|
||||
return h(ListView, {
|
||||
|
|
|
@ -3,6 +3,7 @@ import { classes } from 'util/ui/classes';
|
|||
import { Locale } from 'util/locale';
|
||||
|
||||
export const ListItemShortView: FunctionComponent<{
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
active: boolean;
|
||||
|
@ -12,7 +13,7 @@ export const ListItemShortView: FunctionComponent<{
|
|||
color?: string;
|
||||
|
||||
itemClicked: () => void;
|
||||
}> = ({ title, description, active, expired, icon, isCustomIcon, color, itemClicked }) => {
|
||||
}> = ({ id, title, description, active, expired, icon, isCustomIcon, color, itemClicked }) => {
|
||||
return (
|
||||
<div
|
||||
class={classes({
|
||||
|
@ -20,6 +21,7 @@ export const ListItemShortView: FunctionComponent<{
|
|||
'list__item--active': active,
|
||||
'list__item--expired': expired
|
||||
})}
|
||||
id={id}
|
||||
draggable={true}
|
||||
onClick={itemClicked}
|
||||
>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { ListEntryShort } from 'ui/list/list-entry-short';
|
|||
import { Scrollable } from 'views/components/scrollable';
|
||||
import { Group } from 'models/group';
|
||||
import { Entry } from 'models/entry';
|
||||
import { useLayoutEffect } from 'preact/hooks';
|
||||
|
||||
export const ListView: FunctionComponent<{
|
||||
itemsCount: number;
|
||||
|
@ -16,6 +17,27 @@ export const ListView: FunctionComponent<{
|
|||
|
||||
onScroll: (e: Event) => void;
|
||||
}> = ({ itemsCount, entries, activeItemId, firstItemOffset, totalHeight, onScroll }) => {
|
||||
useLayoutEffect(() => {
|
||||
if (!activeItemId) {
|
||||
return;
|
||||
}
|
||||
const activeItem = document.getElementById(activeItemId);
|
||||
if (!activeItem) {
|
||||
return;
|
||||
}
|
||||
const scroller = activeItem.closest('.scroller');
|
||||
if (!scroller) {
|
||||
return;
|
||||
}
|
||||
const itemRect = activeItem.getBoundingClientRect();
|
||||
const listRect = scroller.getBoundingClientRect();
|
||||
if (itemRect.top < listRect.top) {
|
||||
scroller.scrollTop += itemRect.top - listRect.top;
|
||||
} else if (itemRect.bottom > listRect.bottom) {
|
||||
scroller.scrollTop += itemRect.bottom - listRect.bottom;
|
||||
}
|
||||
}, [activeItemId]);
|
||||
|
||||
return (
|
||||
<div class="list">
|
||||
<div class="list__header">
|
||||
|
|
Loading…
Reference in New Issue
Block a user