stem-core
Version:
Frontend and core-library framework
317 lines (271 loc) • 12.5 kB
JSX
import {UI} from "./UIBase";
import {Button} from "./button/Button";
import {NumberInput} from "./input/Input";
import {RangePanelStyle} from "./RangePanelStyle";
import {Dispatchable} from "../base/Dispatcher";
import {Size} from "./Constants";
function RangePanelInterface(PanelClass) {
class RangePanel extends PanelClass {
}
return RangePanel;
}
class EntriesManager extends Dispatchable {
constructor(entries, options={}) {
super();
this.rawEntries = entries;
this.options = options;
this.cacheEntries();
}
getRawEntries() {
return this.rawEntries;
}
cacheEntries() {
this.cachedEntries = this.sortEntries(this.filterEntries(this.getRawEntries()));
this.dispatch("update");
}
getEntries() {
return this.cachedEntries;
}
getEntriesCount() {
return this.cachedEntries.length;
}
getEntriesRange(low, high) {
return this.cachedEntries.slice(low, high);
}
updateEntries(entries) {
this.rawEntries = entries;
this.cacheEntries();
}
sortEntries(entries) {
return this.getComparator() ? entries.sort(this.getComparator()) : entries;
}
filterEntries(entries) {
const filter = this.getFilter();
return filter ? entries.filter(filter) : entries;
}
getComparator() {
return this.options.comparator;
}
setComparator(comparator) {
this.options.comparator = comparator;
this.cacheEntries();
}
getFilter() {
return this.options.filter;
}
setFilter(filter) {
this.options.filter = filter;
this.cacheEntries();
}
}
// A wrapper for tables which optimizes rendering when many entries / updates are involved. It currently has hardcoded
// row height for functionality reasons.
function RangeTableInterface(TableClass) {
class RangeTable extends UI.Primitive(TableClass, "div") {
constructor(options) {
super(options);
this.lowIndex = 0;
this.highIndex = 0;
}
getRangePanelStyleSheet() {
return RangePanelStyle.getInstance();
}
getRowHeight() {
return this.options.rowHeight || this.getRangePanelStyleSheet().rowHeight;
}
getEntriesManager() {
if (!this.entriesManager) {
this.entriesManager = new EntriesManager(super.getEntries());
}
return this.entriesManager;
}
extraNodeAttributes(attr) {
attr.addClass(this.getRangePanelStyleSheet().default);
}
render() {
const rangePanelStyleSheet = this.getRangePanelStyleSheet();
const fakePanelHeight = (this.getRowHeight() * this.getEntriesManager().getEntriesCount() + 1) + "px";
const headHeight = this.containerHead ? this.containerHead.getHeight() : 0;
this.computeIndices();
// Margin is added at redraw for the case when the scoreboard has horizontal scrolling during a redraw.
const margin = (this.node && this.node.scrollLeft) || 0;
return [
<div ref="tableContainer" className={rangePanelStyleSheet.tableContainer}
style={{paddingTop: headHeight + "px", marginLeft: margin + "px"}}>
<div ref="scrollablePanel" className={rangePanelStyleSheet.scrollablePanel}>
<div ref="fakePanel" className={rangePanelStyleSheet.fakePanel} style={{height: fakePanelHeight}}/>
<table ref="container" className={`${this.styleSheet.table} ${rangePanelStyleSheet.table}`}
style={{marginLeft: -margin + "px"}}>
<thead ref="containerHead">
{this.renderContainerHead()}
</thead>
<tbody ref="containerBody">
{this.renderContainerBody()}
</tbody>
</table>
</div>
</div>,
<div ref="footer" className={rangePanelStyleSheet.footer} style={{marginLeft: margin + "px"}}>
<span ref="tableFooterText">
{this.getFooterContent()}
</span>
<NumberInput ref="jumpToInput" placeholder="jump to..." style={{textAlign: "center",}}/>
<Button ref="jumpToButton" size={Size.SMALL} className={rangePanelStyleSheet.jumpToButton}>Go</Button>
</div>
];
}
applyScrollState() {
this.scrollablePanel.node.scrollTop = this.scrollState;
}
saveScrollState() {
if (this.scrollablePanel && this.scrollablePanel.node) {
this.scrollState = this.scrollablePanel.node.scrollTop;
}
}
renderContainerHead() {
return this.renderTableHead();
}
renderContainerBody() {
// TODO: this method should not be here, and tables should have a method "getEntriesToRender" which will be overwritten in this class.
this.rows = [];
const entries = this.getEntriesManager().getEntriesRange(this.lowIndex, this.highIndex);
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
const RowClass = this.getRowClass(entry);
this.rows.push(<RowClass key={this.getEntryKey(entry, i + this.lowIndex)} index={i + this.lowIndex}
{...this.getRowOptions(entry)} parent={this}/>);
}
return this.rows;
}
getFooterContent() {
if (this.lowIndex + 1 > this.highIndex) {
return `No results. Jump to `;
}
return `${this.lowIndex + 1} ➞ ${this.highIndex} of ${this.getEntriesManager().getEntriesCount()}. `;
}
jumpToIndex(index) {
// Set the scroll so that the requested position is in the center.
const lowIndex = parseInt(index - (this.highIndex - this.lowIndex) / 2 + 1);
const scrollRatio = lowIndex / (this.getEntriesManager().getEntriesCount() + 0.5);
this.scrollablePanel.node.scrollTop = scrollRatio * this.scrollablePanel.node.scrollHeight;
}
computeIndices() {
if (!this.tableContainer || !this.containerHead || !this.footer) {
return;
}
const scrollRatio = this.scrollablePanel.node.scrollTop / this.scrollablePanel.node.scrollHeight;
const entriesCount = this.getEntriesManager().getEntriesCount();
// Computing of entries range is made using the physical scroll on the fake panel.
this.lowIndex = parseInt(scrollRatio * (entriesCount + 0.5));
if (isNaN(this.lowIndex)) {
this.lowIndex = 0;
}
this.highIndex = Math.min(this.lowIndex + parseInt((this.getHeight() - this.containerHead.getHeight()
- this.footer.getHeight()) / this.getRowHeight()), entriesCount);
}
setScroll() {
// This is the main logic for rendering the right entries. Right now, it best works with a fixed row height,
// for other cases no good results are guaranteed. For now, that row height is hardcoded in the class'
// stylesheet.
if (this.inSetScroll) {
return;
}
if (!document.body.contains(this.node)) {
this.tableFooterText.setChildren(this.getFooterContent());
this.containerBody.setChildren(this.renderContainerBody());
return;
}
this.inSetScroll = true;
this.computeIndices();
// Ugly hack for chrome stabilization.
// This padding top makes the scrollbar appear only on the tbody side
this.tableContainer.setStyle("paddingTop", this.containerHead.getHeight() + "px");
this.fakePanel.setHeight(this.getRowHeight() * this.getEntriesManager().getEntriesCount() + "px");
// The scrollable panel must have the exact height of the tbody so that there is consistency between entries
// rendering and scroll position.
this.scrollablePanel.setHeight(this.getRowHeight() * (this.highIndex - this.lowIndex) + "px");
// Update the entries and the footer info.
this.tableFooterText.setChildren(this.getFooterContent());
this.containerBody.setChildren(this.renderContainerBody());
// This is for setting the scrollbar outside of the table area, otherwise the scrollbar wouldn't be clickable
// because of the logic in "addCompatibilityListeners".
this.container.setWidth(this.fakePanel.getWidth() + "px");
this.inSetScroll = false;
}
addCompatibilityListeners() {
// The physical table has z-index -1 so it does not respond to mouse events, as it is "behind" fake panel.
// The following listeners repair that.
this.addNodeListener("mousedown", () => {
this.container.setStyle("pointerEvents", "all");
});
this.container.addNodeListener("mouseup", (event) => {
const mouseDownEvent = new MouseEvent("click", event);
const domElement = document.elementFromPoint(parseFloat(event.clientX), parseFloat(event.clientY));
setTimeout(() => {
this.container.setStyle("pointerEvents", "none");
domElement.dispatchEvent(mouseDownEvent);
}, 100);
});
// Adding listeners that force resizing
this.addListener("setActive", () => {
this.setScroll();
});
this.addListener("resize", () => {
this.setScroll();
});
window.addEventListener("resize", () => {
this.setScroll();
});
}
addTableAPIListeners() {
// This event isn't used anywhere but this is how range updates should be made.
this.addListener("entriesChange", (event) => {
if (!(event.leftIndex >= this.highIndex || event.rightIndex < this.lowIndex)) {
this.setScroll();
}
});
this.addListener("showCurrentUser", () => {
const index = this.getEntriesManager().getEntries().map(entry => entry.userId).indexOf(USER.id) + 1;
this.jumpToIndex(index);
});
// Delay is added for smoother experience of scrolling.
this.attachListener(this.getEntriesManager(), "update", () => {
this.setScroll();
});
}
addSelfListeners() {
this.scrollablePanel.addNodeListener("scroll", () => {
this.setScroll();
});
this.addNodeListener("scroll", () => {
this.tableContainer.setStyle("marginLeft", this.node.scrollLeft);
this.footer.setStyle("marginLeft", this.node.scrollLeft);
this.container.setStyle("marginLeft", -this.node.scrollLeft);
});
window.addEventListener("resize", () => {
this.tableContainer.setStyle("marginLeft", 0);
this.footer.setStyle("marginLeft", 0);
this.container.setStyle("marginLeft", 0);
});
this.jumpToInput.addNodeListener("keyup", (event) => {
if (event.code === "Enter") {
this.jumpToIndex(parseInt(this.jumpToInput.getValue()));
}
});
this.jumpToButton.addClickListener(() => {
this.jumpToIndex(parseInt(this.jumpToInput.getValue()));
});
}
onMount() {
super.onMount();
this.addCompatibilityListeners();
this.addTableAPIListeners();
this.addSelfListeners();
setTimeout(() => {
this.redraw();
})
}
}
return RangeTable;
}
export {RangePanelInterface, RangeTableInterface, EntriesManager};