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 = + `
+

Pagination

+
+ + +
+
`; +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);