1
0
mirror of https://github.com/keeweb/keeweb.git synced 2024-06-26 07:39:04 +02:00

scrolling

This commit is contained in:
antelle 2021-06-05 21:15:44 +02:00
parent 3a4b1f56d9
commit 5140d729e2
No known key found for this signature in database
GPG Key ID: 63C9777AAB7C563C
7 changed files with 89 additions and 206 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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