You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
RnQ/Distro/Template/vlist.mjs

710 lines
20 KiB
JavaScript

function isTruthy(prop) {
if (prop === undefined) return false;
if (prop === "" ) return true;
if (prop === "false" ) return false;
return true;
}
export class VList extends Element {
props;
this(props) {
const {renderItem, renderList, ...rest} = props;
super.this?.(rest);
this.props = rest;
this.renderItem = renderItem ?? this.renderItem;
this.renderList = renderList ?? this.renderList;
}
init(itemHeight = 22) {
this.vlist.slidingWindowSize = Math.round(2 * view.screenBox("frame", "height") / itemHeight);
}
itemAt(index) {
return null;
}
totalItems() {
return 0;
}
indexOf(item) {
return -1;
}
renderList(items) {}
renderItem(item) {}
render() {
const list = [];
if (!this.vlist) return this.renderList(list);
const totalItems = this.totalItems();
const firstIndex = this.vlist.firstBufferIndex;
let lastIndex = this.vlist.lastBufferIndex;
if (this.vlist.itemsTotal != totalItems) {
const firstVisibleIndex = firstIndex + this.vlist.firstVisibleItem?.index || 0;
const lastVisibleIndex = firstIndex + this.vlist.lastVisibleItem?.index;
if (firstVisibleIndex == 0) {
this.post(() => this.vlist.navigate("start"));
return this.renderList([]);
}
if (lastVisibleIndex >= totalItems) {
this.post(() => this.vlist.navigate("end"));
return this.renderList([]);
}
lastIndex = Math.min(totalItems, firstIndex + this.vlist.slidingWindowSize) - 1;
this.post(() => {
this.vlist.itemsAfter = totalItems - this.vlist.itemsBefore - this.children.length;
});
}
if (this.itemsAt) {
let items = this.itemsAt(firstIndex, lastIndex - firstIndex + 1);
for (let item of items)
this.renderElement(list, item);
} else {
for (let index = firstIndex; index <= lastIndex; ++index)
this.renderElement(list, this.itemAt(index));
}
return this.renderList(list);
}
renderElement(elements, item) {
if (item) elements.push(this.renderItem(item));
}
appendElements(index, n) {
if (index === undefined) index = 0;
const totalItems = this.totalItems();
const elements = [];
if (this.itemsAt) {
let items = this.itemsAt(index, n);
for (let item of items)
this.renderElement(elements, item);
index += n;
if (index >= totalItems) index = totalItems;
} else {
for (let i = 0; i < n; ++i, ++index) {
if (index >= totalItems) break;
this.renderElement(elements, this.itemAt(index));
}
}
this.append(elements);
return { moreafter: (totalItems - index) };
}
prependElements(index, n) {
if (index === undefined) index = this.totalItems() - 1;
const elements = [];
if (this.itemsAt) {
let items = this.itemsAt(index - n + 1, n);
for (let item of items)
this.renderElement(elements, item);
index -= n;
if (index < 0) index = -1;
} else {
for (let i = 0; i < n; ++i, --index) {
if (index < 0) break;
this.renderElement(elements, this.itemAt(index));
}
elements.reverse();
}
this.prepend(elements);
return { morebefore: (index < 0 ? 0 : index + 1) };
}
replaceElements(index, n) {
const totalItems = this.totalItems();
const elements = [];
const start = index;
if (this.itemsAt) {
let items = this.itemsAt(index, n);
for (let item of items)
this.renderElement(elements, item);
index += n;
if (index >= totalItems) index = totalItems - 1;
} else {
for (let i = 0; i < n; ++i, ++index) {
if (index >= totalItems) break;
this.renderElement(elements, this.itemAt(index));
}
}
this.patch(elements);
return {
morebefore: start <= 0 ? 0 : start,
moreafter: totalItems - index,
};
}
oncontentrequired(e) {
let {length, start, where} = e.data;
if (where > 0) e.data = this.appendElements(start, length);
else if (where < 0) e.data = this.prependElements(start, length);
else e.data = this.replaceElements(start, length);
return true;
}
}
export class VSelect extends VList {
multiselect = false;
rightButtonSelect = true;
currentItem = null;
anchorIndex = null;
selectedIndexes = {};
checkedItems = {};
recordset = [];
this(props) {
const {recordset, rightButtonSelect, multiselect, multicheck, ...rest} = props;
super.this?.(rest);
this.recordset = recordset ?? [];
this.rightButtonSelect = rightButtonSelect ?? this.rightButtonSelect;
this.multiselect = multiselect ?? this.multiselect;
this.multicheck = multicheck ?? this.multicheck;
console.assert(Array.isArray(this.recordset));
}
itemAt(index) {
return this.recordset?.[index];
}
totalItems() {
return this.recordset?.length ?? 0;
}
indexOf(item) {
return this.recordset?.indexOf(item);
}
itemsAreEqual(item1, item2) {
return item1 === item2;
}
uniqueKey() {
return "key";
}
renderElement(elements, item) {
if (!item) return;
const {currentItem, selectedIndexes, checkedItems} = this;
elements.push(this.renderItem(item, this.itemsAreEqual(item, currentItem), selectedIndexes[this.indexOf(item)] !== undefined, checkedItems[item[this.uniqueKey()]] !== undefined));
}
notifyCurrentChange() {
this.postEvent(new Event("currentchange", { bubbles: true }));
}
componentUpdate(props, kids) {
if (props && props.currentItem)
if (!this.itemsAreEqual(this.currentItem, props.currentItem)) this.notifyCurrentChange();
super.componentUpdate(props, kids);
}
componentUpdateThrottled = this.componentUpdate.throttle(8ms);
render(props) {
if ((props?.recordset && (this.recordset !== props.recordset)) || !this.vlist) {
this.recordset = props?.recordset ?? [];
this.post(() => this.vlist.navigate("start"));
return this.renderList([], props);
}
return super.render(props);
}
itemOfElement(element) {
return this.itemAt(element.index + this.vlist.firstBufferIndex);
}
advanceCentered(index, animated = false) {
if (index < 0 || index > this.totalItems() - 1) return false;
this.selectSingleIndex(index);
this.componentUpdate({ currentItem: this.itemAt(index) });
if (animated) {
this.vlist.advanceTo(index);
} else {
this.vlist.navigateTo(index);
let halfPage = this.getPageSize(true);
if (halfPage > 0) this.vlist.navigateTo(index - halfPage);
}
return true;
}
advanceNext(shift) {
if (!this.currentItem) {
this.currentItem = this.itemOfElement(this.vlist.firstVisibleItem);
} else {
let index = this.indexOf(this.currentItem);
if (++index < this.totalItems()) {
this.currentItem = this.itemAt(index);
this.vlist.advanceTo(index);
} else return true;
}
if (this.multiselect && shift)
this.selectFromAnchorToCurrent();
else
this.selectSingleIndex(this.indexOf(this.currentItem));
this.componentUpdate();
this.notifyCurrentChange();
return true;
}
advancePrevious(shift) {
if (!this.currentItem) {
this.currentItem = this.itemOfElement(this.vlist.lastVisibleItem);
} else {
let index = this.indexOf(this.currentItem);
if (--index >= 0) {
this.currentItem = this.itemAt(index);
this.vlist.advanceTo(index);
} else return true;
}
if (this.multiselect && shift)
this.selectFromAnchorToCurrent();
else
this.selectSingleIndex(this.indexOf(this.currentItem));
this.componentUpdate();
this.notifyCurrentChange();
return true;
}
getPageSize(half = false) {
let first = this.indexOf(this.itemOfElement(this.vlist.firstVisibleItem));
let last = this.indexOf(this.itemOfElement(this.vlist.lastVisibleItem));
let num = last - first + 1;
return half ? Math.max(0, Math.round(num / 2 - 1)) : num;
}
advancePageNext(shift) {
if (!this.currentItem) {
this.currentItem = this.itemOfElement(this.vlist.lastVisibleItem);
} else {
let index = this.indexOf(this.currentItem);
if (index == this.totalItems() - 1) return true;
index = Math.min(index + this.getPageSize(), this.totalItems() - 1);
this.currentItem = this.itemAt(index);
this.vlist.advanceTo(index);
}
if (this.multiselect && shift)
this.selectFromAnchorToCurrent();
else
this.selectSingleIndex(this.indexOf(this.currentItem));
this.componentUpdate();
this.notifyCurrentChange();
return true;
}
advancePagePrevious(shift) {
if (!this.currentItem) {
this.currentItem = this.itemOfElement(this.vlist.firstVisibleItem);
} else {
let index = this.indexOf(this.currentItem);
if (index == 0) return true;
index = Math.max(index - this.getPageSize(), 0);
this.currentItem = this.itemAt(index);
this.vlist.advanceTo(index);
}
if (this.multiselect && shift)
this.selectFromAnchorToCurrent();
else
this.selectSingleIndex(this.indexOf(this.currentItem));
this.componentUpdate();
this.notifyCurrentChange();
return true;
}
advanceFirst(shift) {
if (this.currentItem && this.indexOf(this.currentItem) == 0) return true;
this.currentItem = this.itemAt(0);
if (this.multiselect && shift)
this.selectFromAnchorToCurrent();
else
this.selectSingleIndex(0);
this.vlist.navigateTo("start");
this.notifyCurrentChange();
return true;
}
advanceLast(shift) {
var lastIndex = this.totalItems() - 1;
if (this.currentItem && this.indexOf(this.currentItem) == lastIndex) return true;
this.currentItem = this.itemAt(lastIndex);
if (this.multiselect && shift)
this.selectFromAnchorToCurrent();
else
this.selectSingleIndex(lastIndex);
this.vlist.navigateTo("end");
this.notifyCurrentChange();
return true;
}
selectSingleIndex(index) {
this.anchorIndex = index ?? null;
this.selectedIndexes = {};
if (this.anchorIndex !== null) this.selectedIndexes[this.anchorIndex] = true;
}
selectFromAnchorToCurrent() {
if (this.anchorIndex === null || !this.currentItem) return;
var currentIndex = this.indexOf(this.currentItem);
var start = currentIndex > this.anchorIndex ? this.anchorIndex : currentIndex;
var end = currentIndex > this.anchorIndex ? currentIndex : this.anchorIndex;
this.selectedIndexes = {};
for (let index = start; index <= end; index++)
this.selectedIndexes[index] = true;
}
selectAll() {
if (!this.multiselect) return false;
var totalItems = this.totalItems();
if (totalItems == 0) return false;
this.anchorIndex = 0;
this.currentItem = this.itemAt(totalItems - 1);
this.selectFromAnchorToCurrent();
this.notifyCurrentChange();
this.componentUpdate();
return true;
}
onkeydown(e) {
switch (e.code) {
case "ArrowDown": return this.advanceNext(e.shiftKey);
case "ArrowUp": return this.advancePrevious(e.shiftKey);
case "PageDown": return this.advancePageNext(e.shiftKey);
case "PageUp": return this.advancePagePrevious(e.shiftKey);
case "End": return this.advanceLast(e.shiftKey);
case "Home": return this.advanceFirst(e.shiftKey);
case "KeyA": if (e.ctrlKey) return this.selectAll();
default: return false;
}
this.postEvent(new Event("input", { bubbles: true }));
return true;
}
// ["on ~mousedown"](e) {
// this.state.capture(true);
// }
// ["on ~mouseup"](e) {
// this.state.capture(false);
// }
// ["on ~mousetick"](e) {
// let height = this.state.box("height");
// if (e.y < 0)
// this.vlist.navigate("itemprior");
// else if(e.y > height)
// this.vlist.navigate("itemnext");
// console.log(e.y, height);
// }
handleSelection(option, multiSingle, multiRange, mousemove = false) {
if (!option) return;
var currentChanged = this.currentItem === null || (this.currentItem && this.currentItem[this.uniqueKey()] != option.attributes["key"]);
if (currentChanged) this.currentItem = this.itemOfElement(option);
if (!this.currentItem) return;
if (this.multiselect) {
var currentIndex = this.indexOf(this.currentItem);
if (multiSingle) {
if (this.selectedIndexes[currentIndex] !== undefined)
delete this.selectedIndexes[currentIndex];
else
this.selectedIndexes[currentIndex] = true;
} else if (currentChanged) {
if (multiRange && this.anchorIndex !== null) {
this.selectFromAnchorToCurrent();
} else {
this.anchorIndex = this.indexOf(this.currentItem);
this.selectedIndexes = {};
this.selectedIndexes[currentIndex] = true;
}
} else this.selectSingleIndex(this.indexOf(this.currentItem));
} else this.selectSingleIndex(this.indexOf(this.currentItem));
if (currentChanged || this.multiselect) {
if (mousemove)
this.componentUpdateThrottled();
else
this.componentUpdate();
}
if (currentChanged) this.notifyCurrentChange();
}
isMouseDown = false;
["on mousedown at :root > *"](e, el) {
if (!this.rightButtonSelect && e.button == 2) return false;
if (this.multicheck && (e.ctrlKey || e.isOnIcon)) {
var cr = this.itemOfElement(el);
this.toggleCheck(cr);
this.componentUpdate({ currentItem: cr });
} else if (e.button == 1 || e.button == 2) {
this.isMouseDown = true;
this.handleSelection(el, e.ctrlKey, e.shiftKey);
}
}
["on mouseup at :root > *"](e) {
this.isMouseDown = false;
}
["on mouseenter"](e) {
if (e.button == 0) this.isMouseDown = false;
}
["on mousemove at :root > *"](e, el) {
//if (!this.rightButtonSelect && e.button == 2) return false;
//if (this.isMouseDown && (e.button == 1 || e.button == 2)) this.handleSelection(el, e.ctrlKey, e.shiftKey, true);
}
get value() {
return this.currentItem;
}
get currentIndex() {
return this.indexOf(this.currentItem);
}
isSelected(index) {
return this.selectedIndexes[index] !== undefined;
}
isChecked(key) {
return this.checkedItems[key] !== undefined;
}
checkCurrent() {
if (this.multicheck && this.currentItem !== null) {
this.toggleCheck(this.currentItem);
this.componentUpdate();
}
}
checkAll() {
if (!this.multicheck) return;
for (let group of this.recordset) {
this.checkedItems[group[this.uniqueKey()]] = group;
if (group.items)
for (let rec of group.items)
this.checkedItems[rec[this.uniqueKey()]] = rec;
}
this.componentUpdate();
}
uncheckAll() {
if (!this.multicheck) return;
for (let group of this.recordset) {
delete this.checkedItems[group[this.uniqueKey()]];
if (group.items)
for (let rec of group.items)
delete this.checkedItems[rec[this.uniqueKey()]];
}
this.componentUpdate();
}
toggleCheck(cr) {
var checked = this.checkedItems[cr[this.uniqueKey()]] !== undefined;
if (checked)
delete this.checkedItems[cr[this.uniqueKey()]];
else
this.checkedItems[cr[this.uniqueKey()]] = cr;
if (cr.kind == 1) {
let allChecked = true;
let parentGroup = this.recordset.find((record) => record.kind == 0 && record.groupId == cr.groupId);
if (parentGroup) {
if (parentGroup.items)
for (let rec of parentGroup.items)
if (rec.kind == 1 && rec.groupId == cr.groupId && this.checkedItems[rec[this.uniqueKey()]] === undefined) {
allChecked = false;
break;
}
if (allChecked) {
if (this.checkedItems[parentGroup[this.uniqueKey()]] === undefined)
this.checkedItems[parentGroup[this.uniqueKey()]] = parentGroup;
} else {
if (this.checkedItems[parentGroup[this.uniqueKey()]] !== undefined)
delete this.checkedItems[parentGroup[this.uniqueKey()]];
}
}
} else if (cr.kind == 0)
for (let group of this.recordset)
if (group.items)
for (let rec of group.items)
if (rec.kind == 1 && rec.groupId == cr.groupId)
if (checked)
delete this.checkedItems[rec[this.uniqueKey()]];
else
this.checkedItems[rec[this.uniqueKey()]] = rec;
this.componentUpdate();
}
focusList() {
this.post(() => this.state.focus = true);
}
}
class VTableBody extends VSelect {
this(props, kids) {
const {recordview, uniquekey, ...rest} = props;
super.this?.(rest);
this.recordview = recordview;
this.uniquekey = uniquekey;
}
uniqueKey() {
return this.uniquekey ?? super.uniqueKey();
}
renderList(items) {
return <tbody {this.props}>{items}</tbody>
}
renderItem(item, isCurrent, isSelected, isChecked) {
return this.recordview(item, isCurrent, isSelected, isChecked);
}
}
export class VTable extends Element {
vtbody;
sortField = undefined;
sortOrder = undefined; // true - ascending, false - descending
sortType = "string";
this(props, kids) {
super.this?.(props, kids);
console.assert(kids[0][0] == "columns"); // expect ....
this.columnHeaders = kids[0][2]; // array of 'es
this.recordset = props.recordset ?? [];
this.recordview = props.recordview;
this.uniquekey = props.uniquekey ?? "key";
this.multicheck = isTruthy(props.multicheck);
this.multiselect = isTruthy(props.multiselect);
this.sortable = isTruthy(props.sortable);
this.unanimated = isTruthy(props.unanimated);
console.assert(typeof this.recordview == "function");
this.recordsForView();
}
render() {
var atts = {};
if (this.multicheck) atts.multicheck = true;
return <table {atts} styleset="vlist.css#vtable">
<thead><tr>{this.columnHeaders}</tr></thead>
<VTableBody
recordset={this.sortedset ?? this.recordset}
recordview={this.recordview}
uniquekey={this.uniquekey}
multicheck={this.multicheck}
multiselect={this.multiselect}
unanimated={this.unanimated} />
</table>;
}
init(itemHeight = 22) {
this.vtbody = this.$("tbody");
this.vtbody.init(itemHeight);
}
componentDidMount() {
this.vtbody = this.$("tbody");
}
transformColumnHeaders() {
var field = this.sortField;
for (let header of this.columnHeaders)
if (header[1].field == field && this.sortOrder !== undefined)
header[1].order = this.sortOrder.toString();
else
header[1].order = "none";
}
recordsForView() {
var rs2view = this.recordset;
this.sortedset = null;
if (this.sortField === undefined || this.sortOrder === undefined || !rs2view.length) return rs2view;
if (!this.sortable) return rs2view;
this.sortedset = rs2view = [ ...rs2view ];
var field = this.sortField;
var order = this.sortOrder;
var comparator;
switch (this.sortType) {
case "string":
comparator = order == "asc" ? (a,b) => a[field].toLowerCase().localeCompare(b[field].toLowerCase(), true)
: (a,b) => b[field].toLowerCase().localeCompare(a[field].toLowerCase(), true);
break;
case "date":
comparator = order == "asc" ? (a,b) => (a[field] ? a[field].valueOf() : Number.MAX_VALUE) - (b[field] ? b[field].valueOf() : Number.MAX_VALUE)
: (a,b) => (b[field] ? b[field].valueOf() : -Number.MAX_VALUE) - (a[field] ? a[field].valueOf() : -Number.MAX_VALUE);
break;
case "boolean":
comparator = order == "asc" ? (a,b) => !a[field] && b[field] ? -1 : 0
: (a,b) => !b[field] && a[field] ? -1 : 0;
break;
case "float": case "integer":
comparator = order == "asc" ? (a,b) => {
if (isNaN(a[field]) || isNaN(b[field]))
return a[field].toString().toLowerCase().localeCompare(b[field].toString().toLowerCase(), true);
else
return (a[field] ? parseFloat(a[field]) : Number.MAX_VALUE) - (b[field] ? parseFloat(b[field]) : Number.MAX_VALUE);
} : (a,b) => {
if (isNaN(a[field]) || isNaN(b[field]))
return b[field].toString().toLowerCase().localeCompare(a[field].toString().toLowerCase(), true);
else
return (b[field] ? parseFloat(b[field]) : -Number.MAX_VALUE) - (a[field] ? parseFloat(a[field]) : -Number.MAX_VALUE);
}
break;
default:
return rs2view;
}
rs2view.sort(comparator);
return rs2view;
}
focusList() {
if (this.vtbody) this.vtbody.focusList();
}
["on click at th[field]"](e, th) {
if (!this.sortable) return false;
var field = th.attr["field"];
this.sortType = th.attr["as"] ?? "string";
if (!field) return false;
if (this.sortField != field) this.sortOrder = "asc"; else
switch (this.sortOrder) {
case "asc": this.sortOrder = "desc"; break;
case "desc" : this.sortOrder = undefined; break;
default: this.sortOrder = "asc";
}
this.sortField = field;
this.transformColumnHeaders();
this.recordsForView();
this.componentUpdate();
}
get checkedItems() {
return this.vtbody?.checkedItems ?? {};
}
set checkedItems(v) {
this.vtbody?.componentUpdate({ checkedItems: v });
}
}