diff --git a/lib/ui.js b/lib/ui.js index f1b1b61..2497482 100644 --- a/lib/ui.js +++ b/lib/ui.js @@ -3,6 +3,7 @@ import { createIcon, resolveIcon } from "./ui/icon"; import { createCheckbox, resolveCheckbox } from "./ui/checkbox"; import { setTooltip, resolveTooltip } from "./ui/tooltip"; import Dropdown from "./ui/dropdown"; +import Grid from "./ui/grid"; export { // icon @@ -15,5 +16,7 @@ export { setTooltip, resolveTooltip, // dropdown - Dropdown + Dropdown, + // grid + Grid } diff --git a/lib/ui/checkbox.js b/lib/ui/checkbox.js index ef4f26d..af90e15 100644 --- a/lib/ui/checkbox.js +++ b/lib/ui/checkbox.js @@ -14,8 +14,7 @@ function fillCheckbox(container, type, label) { } } -function createCheckbox(opts) { - opts ??= {}; +function createCheckbox(opts = {}) { const container = document.createElement('label'); container.className = 'checkbox-wrapper'; const input = document.createElement('input'); @@ -56,8 +55,7 @@ function createCheckbox(opts) { return container; } -function resolveCheckbox(container, legacy) { - container ??= document.body; +function resolveCheckbox(container = document.body, legacy) { if (legacy) { const checks = container.querySelectorAll('input[type="checkbox"]'); for (let chk of checks) { diff --git a/lib/ui/dropdown.js b/lib/ui/dropdown.js index f2c7fac..3aa37a6 100644 --- a/lib/ui/dropdown.js +++ b/lib/ui/dropdown.js @@ -97,8 +97,7 @@ class Dropdown { onselected; onexpanded; - constructor(options) { - options ??= {}; + constructor(options = {}) { options.searchplaceholder ??= r('searchHolder', 'Search...'); options.textkey ??= 'text'; options.valuekey ??= 'value'; @@ -287,8 +286,7 @@ class Dropdown { get #expanded() { return this.#container?.style.visibility === 'visible' } - #dropdown(flag) { - flag ??= true; + #dropdown(flag = true) { const options = this.#options; const textkey = options.textkey; let panel = this.#container; @@ -472,8 +470,7 @@ class Dropdown { } } - static resolve(dom) { - dom ??= document.body; + static resolve(dom = document.body) { const selects = dom.querySelectorAll('select'); for (let sel of selects) { const source = [...sel.children].map(it => { diff --git a/lib/ui/grid.js b/lib/ui/grid.js new file mode 100644 index 0000000..2f4e649 --- /dev/null +++ b/lib/ui/grid.js @@ -0,0 +1,607 @@ +import { isMobile, global, nullOrEmpty, throttle, truncate, isPositive } from "../utility"; +import { r } from "../utility/lgres"; +import { createIcon } from "../ui/icon"; +import { createCheckbox } from "../ui/checkbox"; + +const ColumnChangedType = { + Reorder: 'reorder', + Resize: 'resize', + Sort: 'sort' +}; +const RefreshInterval = isMobile() ? 32 : 0; +const MaxColumnBit = 10; +const MaxColumnMask = 0x3ff; +const RedumCount = 4; +const MiniDragOffset = 4; +const MiniColumnWidth = 50; + +class GridColumn { + static create() { return document.createElement('span') } + + static setValue(element, val) { element.innerText = val } + + static getValue(element) { return element.innerText } +} + +class GridInputColumn extends GridColumn { + static createEdit(trigger) { + const input = document.createElement('input'); + input.setAttribute('type', 'input'); + if (typeof trigger === 'function') { + input.addEventListener('change', trigger); + } + return input; + } + + static setValue(element, val) { element.value = val } + + static getValue(element) { return element.value } + + static setEnabled(element, enabled) { element.disabled = enabled !== false } +} + +class GridDropdownColumn extends GridColumn { +} + +class GridCheckboxColumn extends GridColumn { + static createEdit(trigger) { + const check = createCheckbox({ + onchange: typeof trigger === 'function' ? trigger : null + }); + return check; + } + + static setValue(element, val) { element.querySelector('input').checked = val } + + static getValue(element) { return element.querySelector('input').checked } + + static setEnabled(element, enabled) { element.querySelector('input').disabled = enabled !== false } +} + +const ColumnTypes = { + 0: GridColumn, + 1: GridInputColumn, + 2: GridDropdownColumn, + 3: GridCheckboxColumn +}; + +class Grid { + #source; + #currentSource; + #parent; + #el; + #refs; + #rendering; + #selectedColumnIndex = -1; + #selectedIndexes; + #startIndex = 0; + #needResize; + #containerHeight; + #bodyClientWidth; + #rowCount = -1; + #overflows; + #scrollTop; + + columns = []; + langs = { + all: r('allItem', '( All )'), + ok: r('ok', 'OK'), + reset: r('reset', 'Reset') + }; + virtualCount = 100; + rowHeight = 27; + filterRowHeight = 26; + height; + multiSelect = false; + allowHtml = false; + holderDisabled = false; + window = global; + sortIndex = -1; + sortDirection = 'asc'; + + willSelect; + selectedRowChanged; + cellDblClicked; + cellClicked; + rowDblClicked; + columnChanged; + + static ColumnTypes = { + Common: 0, + Input: 1, + Dropdown: 2, + Checkbox: 3 + }; + + constructor(container) { + this.#parent = container; + } + + get source() { return this.#source; } + set source(list) { + if (this.#el == null) { + throw new Error('grid has not been initialized.') + } + this.#source = list; + // TODO: filter to currentSource; + this.#currentSource = list; + this.#containerHeight = list.length * this.rowHeight; + this.#overflows = {}; + this.#selectedColumnIndex = -1; + this.#selectedIndexes = []; + this.#startIndex = 0; + this.#scrollTop = 0; + this.#refs.body.scrollTop = 0; + this.#refs.bodyContent.style.top = '0px'; + this.#refs.bodyContainer.style.height = `${this.#containerHeight}px`; + this.#rowCount = -1; + + if (this.sortIndex >= 0) { + this.sortColumn(true); + } else { + this.resize(); + } + } + get virtual() { return this.#currentSource?.length > this.virtualCount } + get sortKey() { } + get selectedIndexes() { return this.#selectedIndexes } + set selectedIndexes(indexes) { } + get selectedIndex() { } + get loading() { return this.#refs.loading?.style.visibility === 'visible' } + set loading(flag) { + if (this.#refs.loading == null) { + return; + } + if (flag === false) { + this.#refs.loading.style.visibility = 'hidden'; + this.#refs.loading.style.opacity = 0; + } else { + this.#refs.loading.style.visibility = 'visible'; + this.#refs.loading.style.opacity = 1; + } + } + get scrollTop() { return this.#refs.body?.scrollTop; } + set scrollTop(top) { + if (this.#refs.body == null) { + return; + } + this.#refs.body.scrollTop = top; + this.reload(); + } + + init(container = this.#parent) { + this.#el = null; + this.#refs = {}; + this.#rendering = true; + this.#currentSource = this.source; + if (!(container instanceof HTMLElement)) { + throw new Error('no specified parent.'); + } + this.#parent = container; + const grid = document.createElement('div'); + grid.className = 'grid'; + grid.setAttribute('tabindex', 0); + grid.addEventListener('keydown', e => { + let index = this.selectedIndex; + let flag = false; + if (e.key === 'ArrowUp') { + // up + flag = true; + if (index > 1) { + delete this.#currentSource[index].__selected; + index -= 1; + } else { + index = 0; + } + } else if (e.key === 'ArrowDown') { + // down + flag = true; + const count = this.#currentSource?.length ?? 0; + if (index < count - 1) { + delete this.#currentSource[index].__selected; + index += 1; + } + } + if (flag) { + this.selectedIndexes = [index]; + this.scrollToIndex(index); + this.#currentSource[index].__selected = true; + this.refresh(); + if (typeof this.selectedRowChanged === 'function') { + this.selectedRowChanged(index); + } + e.stopPropagation(); + } + }); + container.replaceChildren(grid); + const sizer = document.createElement('span'); + sizer.className = 'grid-sizer'; + grid.appendChild(sizer); + this.#refs.sizer = sizer; + + // header & body + const header = this.#createHeader(); + grid.appendChild(header); + const body = this.#createBody(); + grid.appendChild(body); + + // loading + const loading = document.createElement('div'); + loading.className = 'grid-loading'; + loading.appendChild(createIcon('fa-regular', 'spinner-third')); + this.#refs.loading = loading; + grid.appendChild(loading); + this.#el = grid; + + this.#rendering = false; + if (this.sortIndex >= 0) { + this.sortColumn(true); + } else { + this.resize(); + } + } + + scrollToIndex(index) { + this.#scrollToTop(index * this.rowHeight, true); + } + + resize(force) { + if (this.#rendering || this.#el == null) { + return; + } + const body = this.#refs.body; + let height = this.#refs.header.offsetHeight + 2; + let top = body.offsetTop; + if (top !== height) { + body.style.top = `${height}px`; + top = height; + } + + height = this.height; + if (isNaN(height)) { + height = this.#el.offsetHeight - top; + } else if (height === 0) { + height = this.#refs.bodyContent.offsetHeight; + this.#el.style.height = `${top + height}px`; + } + body.style.height = `${height}px`; + const count = truncate((height - 1) / this.rowHeight) * (RedumCount * 2) + 1; + if (force || count !== this.#rowCount) { + this.#rowCount = count; + this.reload(); + } + this.#bodyClientWidth = body.clientWidth; + } + + reload() { + this.#containerHeight = this.#currentSource.length * this.rowHeight; + this.#adjustRows(this.#refs.bodyContent); + this.refresh(); + } + + refresh() { + if (this.#refs.bodyContent == null) { + throw new Error('body has not been created.'); + } + const rows = this.#refs.bodyContent.children; + const widths = {}; + this.#fillRows(rows, this.columns, widths); + if (this.#needResize && widths.flag) { + this.#needResize = false; + this.columns.forEach((col, i) => { + if (!col.autoResize) { + return; + } + const width = widths[i]; + if (width > 0) { + this.#changeColumnWidth(i, width); + } + }); + } + } + + sortColumn(auto, reload) { } + + #createHeader() { + const thead = document.createElement('table'); + thead.className = 'grid-header'; + const header = document.createElement('tr'); + thead.appendChild(header); + const sizer = this.#refs.sizer; + for (let col of this.columns) { + if (col.visible === false) { + const hidden = document.createElement('th'); + hidden.style.display = 'none'; + if (col.sortable === true) { + hidden.dataset.key = col.key; + hidden.addEventListener('mouseup', e => this.#onHeaderClicked(col, e, true)); + } + header.appendChild(hidden); + continue; + } + // style + if (col.width > 0 || col.shrink) { + col.autoResize = false; + } else { + col.autoResize = true; + this.#needResize = true; + sizer.innerText = col.caption; + let width = sizer.offsetWidth + 20; + if (width < MiniColumnWidth) { + width = MiniColumnWidth; + } + col.width = width; + } + col.align ??= 'left'; + if (col.sortable !== false) { + col.sortable = true; + } + if (col.shrink) { + col.style = { 'text-align': col.align }; + } else { + col.style = { + 'width': col.width, + 'max-width': col.width, + 'min-width': col.width, + 'text-align': col.align + }; + } + // element + const th = document.createElement('th'); + th.dataset.key = col.key; + for (let css of Object.entries(col.style)) { + th.style.setProperty(css[0], css[1]); + } + th.style.cursor = col.sortable ? 'pointer' : 'auto'; + th.addEventListener('mouseup', e => this.#onHeaderClicked(col, e)); + th.addEventListener('mousedown', e => this.#onDragStart(col, e)); + const wrapper = document.createElement('div'); + th.appendChild(wrapper); + if (col.enabled !== false && col.allcheck && col.type === Grid.ColumnTypes.Checkbox) { + const check = createCheckbox({ + onchange: e => this.#onColumnAllChecked(col, e.target.checked) + }); + wrapper.appendChild(check); + } + const caption = document.createElement('span'); + caption = col.caption; + wrapper.appendChild(caption); + // order arrow + if (col.sortable) { + const arrow = document.createElement('layer'); + arrow.className = 'arrow'; + th.appendChild(arrow); + } + // filter + if (col.allowFilter) { + // TODO: filter + } + // resize spliter + if (col.resizable !== false) { + const spliter = document.createElement('layer'); + spliter.className = 'spliter'; + spliter.addEventListener('mousedown', e => this.#onResizeStart(col, e)); + th.appendChild(spliter); + } + // tooltip + !nullOrEmpty(col.tooltip) && th.setAttribute('title', col.tooltip); + header.appendChild(th); + } + const placeholder = document.createElement('th'); + const dragger = document.createElement('div'); + dragger.className = 'dragger'; + const draggerCursor = document.createElement('layer'); + draggerCursor.className = 'dragger-cursor'; + placeholder.append(dragger, draggerCursor); + header.appendChild(placeholder); + + sizer.replaceChildren(); + this.#refs.header = header; + this.#refs.dragger = dragger; + this.#refs.draggerCursor = draggerCursor; + return thead; + } + + #createBody() { + const body = document.createElement('div'); + body.className = 'grid-body'; + body.addEventListener('scroll', e => throttle(this.#onScroll, RefreshInterval, this, e), { passive: true }); + const cols = this.columns; + let height = this.#currentSource.length * this.rowHeight; + let width; + if (height === 0) { + height = 1; + width = 0; + for (let col of cols) { + if (col.visible !== false && !isNaN(col.width)) { + width += col.width + 1; + } + } + width += 1; + } + this.#containerHeight = height; + // body container + const bodyContainer = document.createElement('div'); + bodyContainer.style.position = 'relative'; + bodyContainer.style.minWidth = '100%'; + bodyContainer.style.minHeight = '1px'; + bodyContainer.style.height = `${height}px`; + if (width > 0) { + bodyContainer.style.width = `${width}px`; + } + body.appendChild(bodyContainer); + // body content + const bodyContent = document.createElement('table'); + bodyContent.className = 'grid-body-content'; + bodyContainer.appendChild(bodyContent); + this.#adjustRows(); + // events + if (!this.holderDisabled) { + const holder = document.createElement('div'); + holder.className = 'grid-hover-holder'; + holder.style.display = 'none'; + bodyContainer.appendChild(holder); + body.addEventListener('mousemove', e => throttle(this.#onBodyMouseMove, RefreshInterval, this, e, holder)); + } + this.#refs.body = body; + this.#refs.bodyContainer = bodyContainer; + this.#refs.bodyContent = bodyContent; + + // this.refresh(); + return body; + } + + #adjustRows() { + let count = this.#rowCount; + if (isNaN(count) || count < 0 || !this.virtual) { + count = this.#currentSource.length; + } + const cols = this.columns; + const content = this.#refs.bodyContent; + const exists = content.children.length; + count -= exists; + if (count > 0) { + for (let i = 0; i < count; i += 1) { + const row = document.createElement('tr'); + row.className = 'grid-row'; + row.addEventListener('mousedown', e => this.#onRowClicked(e, exists + 1)); + row.addEventListener('dblclick', e => this.#onRowDblClicked(e)); + cols.forEach((col, j) => { + const cell = document.createElement('td'); + if (col.visible !== false) { + cell.keyid = ((exists + i) << MaxColumnBit) | j; + if (col.style != null) { + for (let css of Object.entries(col.style)) { + cell.style.setProperty(css[0], css[1]); + } + } + if (col.css != null) { + for (let css of Object.entries(col.css)) { + cell.style.setProperty(css[0], css[1]); + } + } + if (col.type === Grid.ColumnTypes.Checkbox) { + cell.appendChild(GridCheckboxColumn.createEdit(e => this.#onRowChanged(e, exists + i, col, e.target.checked))); + } else if (this.allowHtml && col.type != null && isNaN(col.type)) { + cell.appendChild(col.type.create()); + } else { + cell.appendChild(GridColumn.create()); + } + } + row.appendChild(cell); + }); + row.appendChild(document.createElement('td')); + content.appendChild(row); + } + } else if (count < 0) { + for (let i = -1; i >= count; i -= 1) { + // content.removeChild(content.children[exists + i]); + content.children[exists + i].remove(); + } + } + } + + #fillRows(rows, cols, widths) { + const startIndex = this.startIndex; + const selected = this.#selectedIndexes; + const allowHtml = this.allowHtml; + rows.forEach((row, i) => { + const vals = this.#currentSource[startIndex + i]; + if (vals == null) { + return; + } + if (!isPositive(row.children.length)) { + return; + } + const item = vals.values; + if (selected.indexOf(startIndex + i) < 0) { + row.classList.remove('selected'); + } else { + row.classList.add('selected'); + } + // data + const selected = row.dataset.selected === '1'; + cols.forEach((col, j) => { + if (col.visible === false) { + return; + } + let val; + if (col.text != null) { + val = col.text; + } else if (typeof col.filter === 'function') { + val = col.filter(item); + } else { + val = item[col.key]; + if (val?.displayValue != null) { + val = val.displayValue; + } + } + val ??= ''; + // fill + const cell = row.children[j]; + const custom = allowHtml && col.type != null && isNaN(col.type); + let element; + if (vals.__selected ^ selected) { + if (custom) { + element = selected ? + col.type.createEdit(e => this.#onRowChanged(e, startIndex + i, col, col.type.getValue(element))) : + col.type.create(); + cell.replaceChildren(element); + // } else if (col.type !== Grid.ColumnTypes.Checkbox) { + // // TODO: + } else { + element = cell.children[0]; + } + } else { + element = cell.children[0]; + } + let enabled = col.enabled; + if (typeof enabled === 'string') { + enabled = item[enabled]; + } + if (custom) { + col.type.setValue(element, item); + col.type.setEnabled(element, enabled); + } else if (col.type === Grid.ColumnTypes.Checkbox) { + GridCheckboxColumn.setValue(element, val); + GridCheckboxColumn.setEnabled(element, enabled); + } else { + // TODO: input, dropdown, etc... + GridColumn.setValue(element, val); + } + }) + }); + } + + #changeColumnWidth(i, width) { } + + #scrollToTop(top, reload) { } + + #onHeaderClicked(col, e, force) { } + #onDragStart(col, e) { } + #onResizeStart(col, e) { } + #onColumnAllChecked(col, flag) { } + #onScroll(e) { } + #onBodyMouseMove(e, holder) { } + #onRowClicked(e, index, colIndex) { } + #onRowDblClicked(e) { } + #onRowChanged(_e, index, col, value) { + if (this.#currentSource == null) { + return; + } + const item = this.#currentSource[this.#startIndex + index].values; + if (item == null) { + return; + } + const enabled = typeof col.enabled === 'string' ? item[col.enabled] : col.enabled; + if (enabled !== false) { + item[col.key] = value; + item.__changed = true; + if (typeof col.onchanged === 'function') { + col.onchanged.call(this, item, value); + } + } + } +} + +export default Grid; \ No newline at end of file diff --git a/lib/ui/tooltip.js b/lib/ui/tooltip.js index e0fce9d..0a4ac06 100644 --- a/lib/ui/tooltip.js +++ b/lib/ui/tooltip.js @@ -48,8 +48,7 @@ function setTooltip(container, content) { }); } -function resolveTooltip(container) { - container ??= document.body; +function resolveTooltip(container = document.body) { const tips = container.querySelectorAll('[title]'); for (let tip of tips) { const title = tip.getAttribute('title'); diff --git a/lib/utility.js b/lib/utility.js index c3e8c1b..24de190 100644 --- a/lib/utility.js +++ b/lib/utility.js @@ -9,6 +9,28 @@ function isPositive(n) { return !isNaN(n) && n > 0; } +function isMobile() { + return /mobile/i.test(navigator.userAgent); +} + +function throttle(method, delay = 100, context = g, ...args) { + if (method == null) { + return; + } + method.tiid && clearTimeout(method.tiid); + const current = new Date(); + if (method.tdate == null || current - method.tdate > delay) { + method.apply(context, args); + method.tdate = current; + } else { + method.tiid = setTimeout(() => method.apply(context, args), delay); + } +} + +function truncate(v) { + return (v > 0 ? Math.floor : Math.ceil)(v); +} + export { // cookie getCookie, @@ -29,5 +51,9 @@ export { padStart, // variables g as global, - isPositive + isPositive, + isMobile, + // functions + throttle, + truncate } \ No newline at end of file diff --git a/lib/utility/lgres.js b/lib/utility/lgres.js index 962b78f..4019bf9 100644 --- a/lib/utility/lgres.js +++ b/lib/utility/lgres.js @@ -45,8 +45,7 @@ function getStorageKey(lgid) { return `res_${lgid}`; } -async function doRefreshLgres(template) { - template ??= ''; +async function doRefreshLgres(template = '') { const lgid = getCurrentLgId(); const r = await get(`language/${lgid}${template}`); const dict = await r.json(); @@ -81,7 +80,6 @@ function getLanguage(lgres, key, defaultValue) { } function applyLanguage(dom, result) { - dom ??= document.body; for (let text of dom.querySelectorAll('[data-lgid]')) { const key = text.dataset.lgid; if (text.tagName === 'INPUT') { @@ -100,8 +98,7 @@ function applyLanguage(dom, result) { } } -async function init(dom, options) { - options ??= {}; +async function init(dom = document.body, options = {}) { const lgid = getCurrentLgId(); let lgres = localStorage.getItem(getStorageKey(lgid)); let result; diff --git a/lib/utility/request.js b/lib/utility/request.js index eaa508c..1d1d6ba 100644 --- a/lib/utility/request.js +++ b/lib/utility/request.js @@ -8,8 +8,7 @@ function combineUrl(url) { return (consts.path || '') + url; } -function get(url, options) { - options ??= {}; +function get(url, options = {}) { return fetch(combineUrl(url), { method: options.method || 'GET', headers: { @@ -21,8 +20,7 @@ function get(url, options) { }); } -function post(url, data, options) { - options ??= {}; +function post(url, data, options = {}) { // let contentType; if (data instanceof FormData) { // contentType = 'multipart/form-data'; @@ -47,7 +45,7 @@ function post(url, data, options) { }); } -function upload(url, data, options) { +function upload(url, data, options = {}) { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); request.onreadystatechange = function () { @@ -59,7 +57,6 @@ function upload(url, data, options) { } } }; - options ??= {}; if (typeof options.progress === 'function') { request.upload.addEventListener('progress', function (ev) { if (ev.lengthComputable) {