diff --git a/src/_h5ai/private/conf/options.json b/src/_h5ai/private/conf/options.json
index a570da26..fbe3784d 100644
--- a/src/_h5ai/private/conf/options.json
+++ b/src/_h5ai/private/conf/options.json
@@ -48,6 +48,13 @@
The user selected view mode is also stored local in modern browsers
so that it will be persistent.
- modeToggle: boolean, show a view mode toggle in the toolbar, or "next"
+ - paginationItems: array of numbers
+ user selectable amounts of items to display at once until pagination occurs.
+ The first value will be selected as the default. Values are then sorted automatically.
+ Value "0" means display ALL items at once, no pagination needed (as an option).
+ It is recommended to place this value (if desired at all) at the end of the array.
+ The user selected amount is also stored local in modern browsers
+ so that is will be persistent.
- setParentFolderLabels: boolean, set parent folder labels to real folder names
- sizes: array of numbers
the first value indicates the default view size. If only one value
@@ -72,6 +79,7 @@
"maxIconSize": 40,
"modes": ["details", "grid", "icons"],
"modeToggle": false,
+ "paginationItems": [100, 50, 200, 300, 400, 500, 0],
"setParentFolderLabels": true,
"sizes": [20, 40, 60, 80, 100, 140, 180, 220, 260, 300],
"theme": "comity",
diff --git a/src/_h5ai/public/css/lib/view/pagination.less b/src/_h5ai/public/css/lib/view/pagination.less
new file mode 100644
index 00000000..57196a6d
--- /dev/null
+++ b/src/_h5ai/public/css/lib/view/pagination.less
@@ -0,0 +1,103 @@
+// @minWidth: 30px;
+// @height: 28px;
+// @lineHeight: @height;
+// @activeHeight: @height + 2;
+@inputWidth: 35px;
+@inputHeight: 28px;
+@buttonMinWidth: 40px;
+@buttonHeight: 30px;
+@buttonLineHeight: @buttonHeight - 2;
+@buttonPadding: 0 8px;
+// @navHeight: @activeHeight;
+@col-hover-green: #44EEAA;
+@col-placeholder-grey: rgb(170, 170, 170);
+
+#pagination_btm {
+
+ position: -webkit-sticky; /* Safari */
+ position: sticky;
+ bottom: 0;
+
+ .nav_buttons {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ position: -webkit-sticky; /* Safari */
+ position: sticky;
+ bottom: 0;
+ }
+
+ .nav_buttons button {
+ width: @buttonMinWidth; // 20px;
+ height: @buttonHeight; // 30px;
+ appearance: none;
+ border: none;
+ outline: none;
+ cursor: pointer;
+ padding: 0;
+ background-color: @col-link;
+ margin: 5px;
+ transition: 0.4s;
+ color: @col-back;
+ font-size: @font-size;
+ text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.2);
+ box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.2);
+ &:hover {
+ background-color: @col-hover-green
+ }
+ &:disabled {
+ background-color: @col-text-disabled-black;
+ box-shadow: inset 0px 0px 4px rgb(0, 0, 0);
+ cursor: default;
+ }
+ }
+
+ .page_input {
+ display: flex;
+ align-content: flex-end;
+ float: left;
+ margin-left: 5px;
+ margin-right: 5px;
+ font-size: @font-size;
+ > input[type="text"]{
+ width: @inputWidth;
+ height: @inputHeight;
+ background: @col-back;
+ border-radius: 3px;
+ border: 1px solid @col-divider-black;
+ padding: @buttonPadding;
+ text-align: center;
+ vertical-align: baseline;
+ outline: none;
+ box-shadow: none;
+ box-sizing: initial;
+ &::placeholder {
+ color: @col-placeholder-grey;
+ opacity: 1; /* Firefox */
+ }
+ &:focus::placeholder {
+ color: transparent;
+ }
+ }
+
+ > input[type="button"]{
+ min-width: @buttonMinWidth;
+ height: @buttonHeight;
+ line-height: @buttonLineHeight;
+ background: @col-back;
+ border-radius: 3px;
+ border: 1px solid @col-divider-black;
+ text-align: center;
+ padding: @buttonPadding;
+ vertical-align: baseline;
+ outline: none;
+ box-shadow: none;
+ color: @col-text;
+ cursor: pointer;
+ &:hover{
+ background-color: @col-hover-green;
+ }
+ }
+ }
+}
diff --git a/src/_h5ai/public/js/lib/ext/l10n.js b/src/_h5ai/public/js/lib/ext/l10n.js
index 0d74dce8..e1a5ca30 100644
--- a/src/_h5ai/public/js/lib/ext/l10n.js
+++ b/src/_h5ai/public/js/lib/ext/l10n.js
@@ -18,6 +18,7 @@ const defaultTranslations = {
dateFormat: 'YYYY-MM-DD HH:mm',
details: 'details',
+ displayAll: "Display ALL",
download: 'download',
empty: 'empty',
files: 'files',
@@ -25,11 +26,16 @@ const defaultTranslations = {
folders: 'folders',
grid: 'grid',
icons: 'icons',
+ info: 'Informations',
language: 'Language',
lastModified: 'Last modified',
name: 'Name',
noMatch: 'no match',
+ pagination: "Pagination",
+ pagInputBtn: "GO",
+ pagInputTxt: "page",
parentDirectory: 'Parent Directory',
+ perPage: "### per page",
search: 'search',
size: 'Size',
tree: 'Tree',
@@ -62,6 +68,9 @@ const update = lang => {
each(currentLang, (value, key) => {
dom('.l10n-' + key).text(value);
dom('.l10n_ph-' + key).attr('placeholder', value);
+ replace(dom('.l10n_rp-' + key), value);
+ dom('.l10n_val-' + key).val(value);
+ dom('.l10n_title-' + key).prop('title', value)
});
format.setDefaultDateFormat(currentLang.dateFormat);
@@ -70,6 +79,15 @@ const update = lang => {
});
};
+const replace = (elem, value) => {
+ if (!elem.length) {
+ return;
+ }
+ each(elem, el => {
+ el.text = value.replace('###', el.value);
+ });
+};
+
const loadLanguage = isoCode => {
if (loaded[isoCode]) {
return Promise.resolve(loaded[isoCode]);
diff --git a/src/_h5ai/public/js/lib/ext/select.js b/src/_h5ai/public/js/lib/ext/select.js
index ab7d633f..c75e9a21 100644
--- a/src/_h5ai/public/js/lib/ext/select.js
+++ b/src/_h5ai/public/js/lib/ext/select.js
@@ -193,7 +193,7 @@ const init = () => {
if (settings.clickndrag) {
$selectionRect.hide().appTo('#content');
- dom('#content')
+ dom('#view')
.on('mousedown', selectionStart)
.on('drag', ev => ev.preventDefault())
.on('dragstart', ev => ev.preventDefault());
diff --git a/src/_h5ai/public/js/lib/ext/sort.js b/src/_h5ai/public/js/lib/ext/sort.js
index a1eaa9da..4b0fb8e3 100644
--- a/src/_h5ai/public/js/lib/ext/sort.js
+++ b/src/_h5ai/public/js/lib/ext/sort.js
@@ -3,6 +3,7 @@ const event = require('../core/event');
const resource = require('../core/resource');
const allsettings = require('../core/settings');
const store = require('../core/store');
+const pagination = require('../view/pagination');
const settings = Object.assign({
enabled: false,
@@ -22,8 +23,8 @@ const columnClasses = {0: 'label', 1: 'date', 2: 'size'};
const cmpFn = (prop, reverse, ignorecase, natural) => {
return (el1, el2) => {
- const item1 = el1._item;
- const item2 = el2._item;
+ const item1 = el1._item === undefined ? el1 : el1._item;
+ const item2 = el2._item === undefined ? el2 : el2._item;
let res = getTypeOrder(item1) - getTypeOrder(item2);
if (res !== 0) {
@@ -58,16 +59,32 @@ const sortItems = (column, reverse) => {
$headers.rmCls('ascending').rmCls('descending');
$header.addCls(reverse ? 'descending' : 'ascending');
+ if (pagination.isSortHandled(fn)) {
+ return;
+ }
dom(toArray(dom('#items .item:not(.folder-parent)')).sort(fn)).appTo('#items');
};
const onContentChanged = () => {
+ if (pagination.isActive()){
+ return;
+ }
+
+ let {column, reverse} = getSortOrder();
+ sortItems(column, reverse);
+};
+
+const getSortOrder = () => {
const order = store.get(storekey);
const column = order && order.column || settings.column;
const reverse = order && order.reverse || settings.reverse;
+ return {column, reverse};
+}
- sortItems(column, reverse);
-};
+const getSortFunc = () => {
+ let {column, reverse} = getSortOrder();
+ return cmpFn(columnProps[column], reverse, settings.ignorecase, settings.natural);
+}
const addToggles = () => {
const $header = dom('#items li.header');
@@ -94,3 +111,7 @@ const init = () => {
init();
+
+module.exports = {
+ getSortFunc
+}
diff --git a/src/_h5ai/public/js/lib/view/pagination.js b/src/_h5ai/public/js/lib/view/pagination.js
new file mode 100644
index 00000000..401bf67e
--- /dev/null
+++ b/src/_h5ai/public/js/lib/view/pagination.js
@@ -0,0 +1,437 @@
+const {each, includes, dom, values} = require('../util');
+const event = require('../core/event');
+const store = require('../core/store');
+const allsettings = require('../core/settings');
+const base = require('./base');
+
+const paginationItems = [100, 0, 50, 250, 500];
+const settings = Object.assign({
+ paginationItems,
+ hideParentFolder: false,
+}, allsettings.view);
+const defaultSize = settings.paginationItems.length ? settings.paginationItems[0] : 0;
+const sortedSizes = [...new Set(settings.paginationItems)].sort((a, b) => a - b)
+const storekey = 'pagination';
+const paginationTpl =
+ `
`;
+const selectorTpl =
+ ``;
+const $pagination = dom(paginationTpl);
+const btn_cls = {
+ 'btn_first': '<<',
+ 'btn_prev': '<',
+ 'btn_next': '>',
+ 'btn_last': '>>'
+};
+
+let pag_active = false;
+let pag_buttons = [];
+let pag_current_page = 1;
+let pag_items;
+let pag_payload;
+let pag_view;
+let pag_count = 0;
+let pag_parent_folder;
+let pag_rows_pref;
+let sortfn;
+
+const setup = (items) => {
+ updateItems(items);
+ pag_current_page = 1;
+ pag_buttons = [];
+ let $pagination_els = base.$content.find('.nav_buttons');
+ setupNavigation($pagination_els);
+ pag_active = true;
+ updateSortFunc();
+ sort(sortfn());
+ setCurrentPage(1);
+}
+
+const updateSortFunc = () => {
+ // Lazy load because sort module needs us loaded beforehand
+ sortfn = require('../ext/sort').getSortFunc;
+}
+
+const updateItems = (items) => {
+ if (!items){
+ return; // use cached items instead
+ }
+ pag_items = items;
+ popParentFolder(pag_items);
+ totalPages();
+ return;
+}
+
+const clear = () => {
+ if (pag_active){
+ pag_buttons.forEach(e => e.remove());
+ pag_buttons = [];
+ }
+ pag_active = false;
+}
+
+const isActive = () => {
+ return pag_active;
+}
+
+const totalPages = () => {
+ if (pag_rows_pref == 0){ // ALL
+ return pag_count = 1;
+ }
+ pag_count = Math.ceil(pag_items.length / pag_rows_pref);
+ return pag_count;
+}
+
+const popParentFolder = (items) => {
+ if (items.length > 0 && !settings.hideParentFolder){
+ pag_parent_folder = items.shift();
+ return;
+ }
+ pag_parent_folder = undefined;
+}
+
+const pushParentFolder = (items) => {
+ if (pag_parent_folder && items[0] !== pag_parent_folder) {
+ items.unshift(pag_parent_folder);
+ }
+}
+
+const setCurrentPage = (page) => {
+ if (!page) {
+ page = (pag_current_page <= pag_count) ? pag_current_page : pag_count;
+ }
+ pag_current_page = page;
+
+ const paginatedItems = computeSlice(pag_items, page, pag_rows_pref);
+
+ pushParentFolder(paginatedItems);
+
+ updateButtons();
+ if (pag_count <= 1) {
+ base.$content.find('.nav_buttons').addCls('hidden');
+ pag_active = false;
+ } else {
+ base.$content.find('.nav_buttons').rmCls('hidden');
+ pag_active = true;
+ }
+ pag_view.doSetItems(paginatedItems);
+}
+
+const computeSlice = (items, page, rows_per_page) => {
+ if (!rows_per_page) { // ALL
+ return items;
+ }
+ page--;
+ const start = rows_per_page * page;
+ const end = start + rows_per_page;
+ return items.slice(start, end);
+}
+
+const sort = (fn) => {
+ // We don't need parent folder item, so we don't filterPayload()
+ pag_items = values(pag_payload.content).sort(fn);
+}
+
+const setupNavigation = (container) => {
+ each(container, key => {
+ key.innerHTML = "";
+ });
+
+ each(container, el => {
+ for (let key in btn_cls) {
+ const btn = paginationButton(key, btn_cls[key]);
+ el.appendChild(btn);
+ pag_buttons.push(btn);
+ }
+ });
+
+ each(container, key => {
+ // Page status numbers
+ let div = updatePageStatus(null);
+ key.insertBefore(div, key.childNodes[2]);
+ pag_buttons.push(div);
+
+ // Manual page number selection
+ div = document.createElement('div');
+ div.classList.add('page_input');
+ let {input_field, input_btn} = pageInputForm();
+ div.appendChild(input_field);
+ div.appendChild(input_btn);
+ key.appendChild(div);
+ pag_buttons.push(input_field);
+ pag_buttons.push(input_btn);
+ });
+}
+
+const paginationButton = (classname, arrow) => {
+ const button = document.createElement('button');
+ button.innerText = arrow;
+ button.classList.add('nav_button');
+
+ button.id = classname;
+
+ switch (classname) {
+ case 'btn_prev':
+ button.req_page = () => pag_current_page - 1;
+ button.disabled = true;
+ break;
+ case 'btn_next':
+ button.req_page = () => pag_current_page + 1;
+ button.disabled = false;
+ break;
+ case 'btn_last':
+ button.req_page = () => pag_count;
+ button.disabled = false;
+ break;
+ default: // 'btn_first'
+ button.req_page = () => 1;
+ button.disabled = true;
+ }
+ button.addEventListener('click', function() {
+ setCurrentPage(this.req_page());
+ });
+ return button;
+};
+
+const updateButtons = () => {
+ const prev_buttons = dom('#btn_first, #btn_prev');
+ const next_buttons = dom('#btn_next, #btn_last');
+ if (pag_current_page <= 1) {
+ each(prev_buttons, button => button.disabled = true);
+ each(next_buttons, button => button.disabled = false);
+ } else if (pag_current_page >= pag_count && pag_current_page > 1) {
+ each(next_buttons, button => button.disabled = true);
+ each(prev_buttons, button => button.disabled = false);
+ } else {
+ const nav_buttons = dom('#btn_first, #btn_prev, #btn_next, #btn_last');
+ each(nav_buttons, button => button.disabled = false);
+ }
+ const pag_pos = dom('.pag_pos');
+ each(pag_pos, el => updatePageStatus(el));
+}
+
+const updatePageStatus = (div) => {
+ const status = pag_current_page.toString().concat('/', pag_count.toString());
+ if (!div) {
+ const div = document.createElement('div');
+ div.appendChild(document.createTextNode(status));
+ div.classList.add('pag_pos');
+ return div;
+ }
+ return div.innerText = status;
+}
+
+const pageInputForm = () => {
+ const input_field = document.createElement('input');
+ input_field.type = 'text';
+ // Use title instead of placeholder due to some translations not fitting in
+ input_field.classList.add('l10n_title-pagInputTxt'); // input_field.title = 'page';
+ input_field.placeholder = '...';
+
+ const input_btn = document.createElement('input');
+ input_btn.type = 'button';
+ input_btn.classList.add('l10n_val-pagInputBtn'); // input_btn.value = 'GO';
+
+ input_btn.addEventListener('click', () => {
+ if (input_field.value !== '' && input_field.value !== pag_current_page) {
+ let parsed = parseInt(input_field.value, 10);
+ if (!isNaN(parsed)) {
+ setCurrentPage(parsed);
+ }
+ }
+ input_field.value = "";
+ input_field.focus();
+ });
+
+ input_field.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && input_field.value && /[^\s]/.test(input_field.value)) {
+ if (input_field.value !== pag_current_page) {
+ e.preventDefault();
+ let parsed = parseInt(input_field.value, 10);
+ if (!isNaN(parsed) && parsed !== pag_current_page) {
+ setCurrentPage(parsed);
+ }
+ }
+ input_field.value = "";
+ input_field.focus();
+ };
+ });
+
+ // Only allow digits, new line and max page, no leading zero or spaces
+ setInputFilter(input_field, (value) => {
+ return /^[^0\s][\d]*$/.test(value) && value <= pag_count;
+ });
+
+ return {input_field, input_btn};
+}
+
+// Restricts input for the given textbox to the given inputFilter function.
+// In the future we could use beforeinput instead.
+function setInputFilter(textbox, inputFilter) {
+ ["input", "keydown", "keyup", "mousedown", "mouseup", "select",
+ "contextmenu", "drop"].forEach(function(event) {
+ textbox.addEventListener(event, function(e) {
+ if (this.value === '') {
+ this.oldValue = this.value;
+ }
+ if (inputFilter(this.value)) {
+ this.oldValue = this.value;
+ this.oldSelectionStart = this.selectionStart;
+ this.oldSelectionEnd = this.selectionEnd;
+ } else if (this.hasOwnProperty("oldValue")) {
+ this.value = this.oldValue;
+ this.setSelectionRange(this.oldSelectionStart, this.oldSelectionEnd);
+ } else {
+ this.value = "";
+ }
+ });
+ });
+}
+
+const initPagSelector = () => {
+ if (settings.paginationItems.length > 0) {
+ dom(selectorTpl).appTo('#sidebar');
+
+ document.querySelector('#pag_select')
+ .addEventListener('change', onSelect);
+
+ for (let option of addOptions(getCachedPref())) {
+ option.appTo('#pag_select');
+ }
+ }
+};
+
+function onSelect() {
+ setPref(parseInt(this.value, 10));
+ onPagPrefUpdated();
+}
+
+const addOptions = (cached_pref) => {
+ const options = [];
+ let set_default = false;
+ for (let size of sortedSizes) {
+ let element;
+ if (size === cached_pref && !set_default) {
+ element = dom(``);
+ set_default = true;
+ } else {
+ element = dom(``);
+ }
+ element.addCls((size === 0) ? 'l10n-displayAll' : 'l10n_rp-perPage');
+ options.push(element);
+ }
+ return options;
+}
+
+const onLocationChanged = () => {
+ // Workaround to append this to the sidebar at the last position
+ // since the view module includes us before the other extensions
+ if (dom('#pag_select').length === 0) {
+ initPagSelector();
+ }
+}
+
+const setPayload = (payload) => {
+ // Not a copy, but we probably won't alter it anyway.
+ pag_payload = payload;
+}
+
+const getCachedPref = () => {
+ if (pag_rows_pref === undefined)
+ return defaultSize;
+ return pag_rows_pref;
+};
+
+// The module won't work if a view is not set first. We need to reuse some funcs
+const setView = (view) => {
+ pag_view = view;
+}
+
+const canHandle = (items) => {
+ clear();
+ if (items.length > getCachedPref()) {
+ // Probably won't alter it, so we don't make a copy to save memory.
+ setup(items);
+ return true;
+ }
+ return false;
+}
+
+const isSortHandled = (fn) => {
+ if (!pag_active) {
+ return false;
+ }
+ sort(fn);
+ setCurrentPage();
+ return true;
+}
+
+const onPagPrefUpdated = () => {
+ if (pag_active) {
+ totalPages();
+ setCurrentPage();
+ return;
+ }
+ const pref = getCachedPref();
+ if (values(pag_payload.content).length > pref && pref != 0) {
+ setup(pag_view.filterPayload(pag_payload));
+ }
+}
+
+const isRefreshHandled = (item) => {
+ setPayload(item);
+ // Block if pagination is active
+ if (values(item.content).length > getCachedPref()) {
+ if (pag_active){
+ updateItems(pag_view.filterPayload(item));
+ sort(sortfn()); // initial sort
+ setCurrentPage();
+ return true;
+ }
+ setup(pag_view.filterPayload(item));
+ return true;
+ }
+ // No need for pagination, recreate the items, hide & pass to default logic
+ if (pag_active){
+ updateItems(pag_view.filterPayload(item));
+ setCurrentPage(1);
+ clear();
+ return true;
+ }
+ // We are not interested in handling the items
+ return false;
+}
+
+const setPref = (size) => {
+ const stored = store.get(storekey);
+ size = (size !== undefined) ? size : stored ? stored : defaultSize;
+ size = includes(settings.paginationItems, size) ? size : defaultSize;
+ store.put(storekey, size);
+ pag_rows_pref = size;
+}
+
+const init = () => {
+ setPref();
+ event.sub('location.changed', onLocationChanged);
+};
+
+init();
+
+module.exports = {
+ $el: $pagination,
+ canHandle,
+ isActive,
+ isRefreshHandled,
+ isSortHandled,
+ setPayload,
+ setView
+}
diff --git a/src/_h5ai/public/js/lib/view/view.js b/src/_h5ai/public/js/lib/view/view.js
index aff35efb..7af59b9f 100644
--- a/src/_h5ai/public/js/lib/view/view.js
+++ b/src/_h5ai/public/js/lib/view/view.js
@@ -6,6 +6,7 @@ const resource = require('../core/resource');
const store = require('../core/store');
const allsettings = require('../core/settings');
const base = require('./base');
+const pagination = require('./pagination');
const modes = ['details', 'grid', 'icons'];
const sizes = [20, 40, 60, 80, 100, 150, 200, 250, 300, 350, 400];
@@ -183,6 +184,12 @@ const checkHint = () => {
};
const setItems = items => {
+ if (!pagination.canHandle(items)) {
+ doSetItems(items);
+ }
+};
+
+const doSetItems = items => {
const removed = map($items.find('.item'), el => el._item);
$items.find('.item').rm();
@@ -218,6 +225,15 @@ const onLocationChanged = item => {
item = location.getItem();
}
+ pagination.setPayload(item);
+
+ const items = filterPayload(item);
+
+ setHint('empty');
+ setItems(items);
+};
+
+const filterPayload = item => {
const items = [];
if (item.parent && !settings.hideParentFolder) {
@@ -229,12 +245,18 @@ const onLocationChanged = item => {
items.push(child);
}
});
-
- setHint('empty');
- setItems(items);
-};
+ return items;
+}
const onLocationRefreshed = (item, added, removed) => {
+ if (added.length === 0 && removed.length === 0){
+ return;
+ }
+
+ if (pagination.isRefreshHandled(item)) {
+ return;
+ }
+
const add = [];
each(added, child => {
@@ -263,6 +285,7 @@ const init = () => {
set();
$view.appTo(base.$content);
+ pagination.$el.appTo(base.$content);
$hint.hide();
format.setDefaultMetric(settings.binaryPrefix);
@@ -277,7 +300,9 @@ init();
module.exports = {
$el: $view,
+ filterPayload,
setItems,
+ doSetItems,
changeItems,
setLocation: onLocationChanged,
setHint,
@@ -288,3 +313,6 @@ module.exports = {
getSize,
setSize
};
+
+// For code reuse purposes
+pagination.setView(module.exports);