UNPKG

x4js

Version:

X4 framework

1,177 lines (922 loc) 24.6 kB
/** * ___ ___ __ * \ \/ / / _ * \ / /_| |_ * / \____ _| * /__/\__\ |_| * * @file spreadsheet.ts * @author Etienne Cochard * * @copyright (c) 2024 R-libre ingenierie * * Use of this source code is governed by an MIT-style license * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT. **/ import { Component, ComponentContent, ComponentEvents, ComponentProps, EvClick, EvContextMenu, EvDblClick, EvSelectionChange, componentFromDOM } from '../../core/component'; import { GridColumn } from '../gridview/gridview' import { class_ns, isNumber, isString, setWaitCursor } from '../../core/core_tools'; import { CoreEvent, EventCallback, EventMap } from '../../core/core_events'; import { kbNav } from '../../core/core_tools'; import { Icon } from '../icon/icon'; import { Image } from '../image/image' import { Box } from '../boxes/boxes'; import { CSizer } from '../sizers/sizer' import { Viewport } from '../viewport/viewport'; import { SimpleText } from '../label/label'; import check_icon from "../checkbox/check.svg"; import "./spreadsheet.module.scss" import { CoreElement, EvViewChange } from '../../x4.js'; interface CellRef { col: number; row: number; } export type CellRenderer = (row: number, col: number, content: any) => Component; function mkid(row: number, col: number) { return ((row & 0xfffff) << 12) | (col & 0xfff); } /** * */ export interface EvChange extends CoreEvent { } export interface StoreEvents extends EventMap { changed: EvChange; } export class Store extends CoreElement<StoreEvents> { private _maxrows: number; private _data: Map<number, any>; private _lock: number; // lock private _change: boolean; constructor() { super(); this._data = new Map(); this._lock = 0; this._change = false; this._maxrows = 0; } setMaxRowCount(rows: number) { if (this._maxrows == rows) { return } if (rows < this._maxrows) { const n = new Map<number, any>(); this._data.forEach((v, k) => { const row = k >> 12; if (row <= rows) { n.set(k, v); } }); } this._maxrows = rows; this._changed() } getRowCount(): number { return this._maxrows; } setData(row: number, col: number, data: any) { this._data.set(mkid(row, col), data); if (row > this._maxrows) { this._maxrows = row; } this._changed(); } getData(row: number, col: number) { return this._data.get(mkid(row, col)); } lock() { this._lock++; } unlock() { if (this._lock) { this._lock--; if (!this._lock && this._change) { this._changed(); } } } private _changed() { if (!this._lock) { this.fire("changed", {}); this._change = false; } else { this._change = true; } } } /** * */ const SCROLL_LIMIT = 200; export interface SpreadsheetEvents extends ComponentEvents { click?: EvClick; dblClick?: EvDblClick; contextMenu?: EvContextMenu; selectionChange?: EvSelectionChange; } export interface SpreadsheetProps extends ComponentProps { footer?: boolean; store: Store; columns: GridColumn[]; click?: EventCallback<EvClick>; dblClick?: EventCallback<EvDblClick>; contextMenu?: EventCallback<EvContextMenu>; selectionChange?: EventCallback<EvSelectionChange>; } /** * we can handle * 4_095 cols and (1_048_575-1)/2 rows (this is a chrome limitation max pixels of scrollbars ) */ /** * */ @class_ns("x4") export class Spreadsheet<P extends SpreadsheetProps = SpreadsheetProps, E extends SpreadsheetEvents = SpreadsheetEvents> extends Component<P, E> { private _columns: GridColumn[]; private _store: Store; private _lock: number; private _dirty: number; private _row_height: number; private _left: number; private _top: number; private _body: Component; private _viewport: Component; private _fheader: Box; // fixed col header private _hheader: Box; // col header private _vheader: Box; // vertical row header private _ffooter: Box; // fixed footer private _footer: Box; // footer private _vis_rows: Map<number, { h: Component, r: Component }>; private _start: number; private _end: number; private _selection: Set<number>; private _num_fmt = new Intl.NumberFormat('fr-FR'); private _mny_fmt = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); private _dte_fmt = new Intl.DateTimeFormat('fr-FR', {}); private _has_fixed: boolean; private _has_footer: boolean; constructor(props: P) { super(props); this._lock = 0; this._dirty = 0; this._row_height = 32; this._left = 0; this._top = 0; this._vis_rows = new Map(); this._selection = new Set(); this._has_fixed = false; this._has_footer = props.footer; this._columns = props.columns.map(x => x); this.mapPropEvents(props, "click", "dblClick", "contextMenu", "selectionChange"); this.lock(true); this.setAttribute("tabindex", 0); this.addDOMEvent("created", () => { this._init(); this._dirty = 1; this.lock(false); }); this.addDOMEvent("resized", () => { this._updateFlexs(); this._computeFullSize(); this._update(true); }); this.addDOMEvent("keydown", (e) => { this._on_key(e); }) if (props.store) { this.setStore(props.store); } } /** * */ private _on_key(ev: KeyboardEvent) { if (this.isDisabled()) { return; } switch (ev.key) { case "ArrowDown": { this.navigate(kbNav.next); break; } case "ArrowUp": { this.navigate(kbNav.prev); break; } case "ArrowLeft": { this.navigate(kbNav.left); break; } case "ArrowRight": { this.navigate(kbNav.right); break; } case "Home": { this.navigate(kbNav.first); break; } case "End": { this.navigate(kbNav.last); break; } case "PageDown": { this.navigate(kbNav.pgdn); break; } case "PageUp": { this.navigate(kbNav.pgup); break; } default: return; } ev.preventDefault(); ev.stopPropagation(); } /** * */ navigate(sens: kbNav) { if (!this._selection.size) { if (sens == kbNav.next || sens == kbNav.pgdn) { sens = kbNav.first; } else { sens = kbNav.last; } } const getLineSel = ( top: boolean ) => { let m: number, M: number; let col: number; this._selection.forEach( x => { const row = x>>12; if( m===undefined || m>row ) { m = row; col=x&0xff; } if( M===undefined || M<row ) { M = row; col=x&0xff; } } ); return [top ? m : M, col] } if (sens == kbNav.first || sens == kbNav.last) { let nline = sens == kbNav.first ? 0 : this._store.getRowCount() - 1; this._clearSelection(false); this._addSelection(mkid(nline,0), true); this._scrollToIndex(nline); return true; } else if (sens == kbNav.prev || sens == kbNav.next) { const [fline,col] = getLineSel( sens == kbNav.prev ); let nline = sens == kbNav.next ? fline + 1 : fline - 1; if (nline >= 0 && nline < this._store.getRowCount()) { this._clearSelection(false); this._addSelection( mkid(nline,col), true); this._scrollToIndex( nline ); return true; } } else if (sens == kbNav.pgdn || sens == kbNav.pgup) { const pgh = this._vis_rows.size; const [fline,col] = getLineSel( sens == kbNav.pgup ); let sby = sens == kbNav.pgdn ? pgh : -pgh; let nline = fline + sby; if (nline < 0) { nline = 0; } else if (nline >= this._store.getRowCount()) { nline = this._store.getRowCount() - 1; } if (nline != fline) { this._clearSelection( false ); this._addSelection(mkid(nline,col), true); if (this._store.getRowCount() < SCROLL_LIMIT) { sby *= this._row_height; } this._viewport.dom.scrollBy(0, sby); return true; } } else if( sens==kbNav.left || sens==kbNav.right ) { const [fline,col] = getLineSel( sens == kbNav.left ); let ncol = sens == kbNav.right ? col+1 : col-1; if (ncol >= 0 && ncol < this._columns.length ) { this._clearSelection(false); this._addSelection( mkid(fline,ncol), true); //this._scrollToIndex( nline ); return true; } } return false; } /** * */ private _scrollToIndex(index: number, block = 'nearest') { // is it already visible ? let ref = mkid(index,0); let rows = this.queryAll(`.cell[data-ref="${ref}"]`); if (rows.length) { rows[0].scrollIntoView({ block: block as any }); } // nope, refill else { let top = index; if (this._store.getRowCount() < SCROLL_LIMIT) { top *= this._row_height; } this._viewport.dom.scrollTo(0, top); } } /** * */ setStore(store: Store) { const on_change = (ev: EvChange) => { if (!this._viewport) { // not created return; } //if (ev.change_type == 'change') { this._selection.clear(); //} this._updateFlexs(); this._computeFullSize(); this._update(true); } // unlink previous observer if (this._store) { this._store.off('changed', on_change); } if (store) { this._store = store; this._store.on('changed', on_change); } else { this._store = null; } } /** * */ lock(lock: boolean) { if (lock) { this._lock++; } else { if (--this._lock == 0 && this._dirty) { this._update(true); } } } private _getColCount() { return this._columns.length; } private _getCol(index: number) { return this._columns[index]; } /** * */ private _buildColHeader(fixed: boolean) { // row header const els: Component[] = []; const count = this._getColCount(); for (let col = 0; col < count; col++) { const cdata = this._getCol(col); if ((!!cdata.fixed) != fixed) { continue; } const sizer = new CSizer("right"); sizer.on("stop", () => { this._updateFlexs(); }) sizer.on("resize", (ev) => { cdata.width = ev.size; cdata.flex = 0; const cols = this.queryAll(`[data-col="${col}"]`) cols.forEach(c => { c.setStyleValue("width", ev.size + "px"); }); const rh = header.getBoundingRect(); if (!fixed) { this._body.setStyleValue("width", rh.width + "px"); } else { this.setStyleVariable("--fixed-width", rh.width + "px"); } }) const cell = new Component({ cls: `cell`, attrs: { "data-col": col }, style: { width: cdata.width ? cdata.width + "px" : undefined }, content: [ new SimpleText({ text: cdata.title, align: cdata.header_align ?? "left" }), new Component({ cls: "sorter" }), sizer ] }); /* cell.addDOMEvent("touchend", () => { const last = cell.getInternalData("touchend"); const now = Date.now(); const delta = last ? now - last : 0; if (delta > 30 && delta < 300) { this._sortCol(col); } else { cell.setInternalData("touchend", now); } }) cell.addDOMEvent("dblclick", () => { this._sortCol(col); }); */ els.push(cell); } if (fixed && els.length == 0) { return null; } const header = new Box({ cls: "col-header", content: els }); header.setClass("fixed", fixed); return header; } /** * */ private _buildColFooter(fixed: boolean) { // row header const els: Component[] = []; const count = this._getColCount(); for (let col = 0; col < count; col++) { const cdata = this._getCol(col); if ((!!cdata.fixed) != fixed) { continue; } const cell = new Component({ cls: `cell`, attrs: { "data-col": col }, style: { width: cdata.width ? cdata.width + "px" : undefined }, content: [ new SimpleText({ text: cdata.footer_val }), ] }); /* cell.addDOMEvent("dblclick", () => { this._sortCol(col); }); */ els.push(cell); } if (fixed && els.length == 0) { return null; } const header = new Box({ cls: "col-footer", content: els }); header.setClass("fixed", fixed); return header; } /** * */ private _renderCell(row: number, column: GridColumn, extra_cls: string[]): ComponentContent { const col = column.id; const type = column.type; let data = this._store.getData( row, col ); if (data === undefined || data === null) { return null; } let cls = ""; //if( column.classifier ) { // extra_cls.push( column.classifier( data, rec, col ) ); //} //if (data instanceof Function) { // return data(rec, col); //} if (column.formatter) { return column.formatter(data); } switch (type) { case "checkbox": { if (data) { return new Icon({ cls: "cell-check" + cls, iconId: check_icon }); } return undefined; } case "image": { if (isString(data)) { return new Image({ cls, src: data, fit: "scale-down" }); } return undefined; } case "number": { if (!isNumber(data)) { return "NaN"; } data = this._num_fmt.format(data as number); break; } case "money": { if (!isNumber(data)) { return "NaN"; } data = this._mny_fmt.format(data as number); break; } case "percent": { return new Box({ cls: "percent" + cls, content: new Component({ cls: "bar", width: data + "%" }) }); } case "icon": { return new Icon({ cls, iconId: data + "" }); } case "date": { data = this._dte_fmt.format(data as Date); break; } default: { data = data + ""; break; } } return new Component({ tag: "span", cls, content: data }); } /** * */ private _buildRow(rowid: number, top: number) { const els: Component[] = []; const count = this._getColCount(); for (let col = 0; col < count; col++) { const cdata = this._getCol(col); if (cdata.fixed) { continue; } const extra: string[] = [] const content = this._renderCell(rowid, cdata, extra); const el = new Component({ cls: "cell", attrs: { "data-col": col }, style: { width: cdata?.width ? cdata.width + "px" : undefined }, content }); switch (cdata.align) { case "center": el.addClass( "align-center" ); break; case "right": el.addClass( "align-right" ); break; } if (extra.length) { el.addClass(extra.join(' ')); } if (cdata.type) { el.addClass(cdata.type); } const ref = mkid(rowid, col); if (this._selection.has(ref)) { el.addClass("selected"); } el.setInternalData("col", col); el.setInternalData("row", rowid) el.setData("ref", ref + ""); els.push(el); } return new Box({ cls: "row", style: { top: top.toFixed(2) + "px" }, content: els }); } /** * */ private _buildRowHeader(rowid: number, top: number) { const cols: Component[] = []; const count = this._getColCount(); for (let col = 0; col < count; col++) { const cdata = this._getCol(col); if (!cdata?.fixed) { continue; } const content = this._renderCell(rowid, cdata, [cdata.type]); let align = "start"; switch (cdata.align) { default: align = "start"; break; case "center": align = "center"; break; case "right": align = "end"; break; } const el = new Component({ cls: "cell", style: { width: cdata?.width ? cdata.width + "px" : undefined, justifyContent: align }, content }); if (cdata.type) { el.addClass(cdata.type); } el.setInternalData("col", col); el.setInternalData("row", rowid); el.setData("ref", mkid(rowid, col) + ""); if (this._selection.has(mkid(col, rowid))) { el.addClass("selected"); } cols.push(el); } return new Box({ cls: "row", style: { top: top + "px" }, content: cols }); } /** * */ private _updateFlexs() { let maxw = 0; let flexc = 0; const ccount = this._getColCount(); for (let x = 0; x < ccount; x++) { const cdata = this._getCol(x); if (!cdata.fixed && cdata.flex) { flexc += cdata.flex; } else { maxw += cdata.width; } } if (flexc) { const width = this._viewport.dom.clientWidth; const delta = width - maxw; const fw = delta / flexc; for (let col = 0; col < ccount; col++) { const cdata = this._getCol(col); if (!cdata.fixed && cdata.flex) { cdata.width = Math.max(cdata.flex * fw, 32); const cols = this.queryAll(`[data-col="${col}"]`) cols.forEach(c => { c.setStyleValue("width", cdata.width + "px"); }); } } } } /** * */ private _computeFullSize() { let maxw = 0; let maxfw = 0; const ccount = this._getColCount(); for (let x = 0; x < ccount; x++) { const cdata = this._getCol(x); let w = 0; if (cdata.fixed) { this._has_fixed = true; } if (cdata.width) { w += cdata.width; } if (cdata.fixed) { maxfw += w; } else { maxw += w; } } const maxr = this._store ? this._store.getRowCount() : 0; let maxh = maxr; if (maxr < SCROLL_LIMIT) { maxh *= this._row_height; } else { const height = this._body.dom.parentElement.clientHeight; const npage = height / this._row_height; maxh = maxr - Math.floor(npage) + npage * this._row_height; } this.setStyleVariable("--fixed-width", maxfw + "px"); this._body.setStyleValue("height", maxh + "px"); this._body.setStyleValue("width", maxw + "px"); this._vheader.setStyleValue("height", maxh + "px"); } /** * */ private _init() { this._body = new Component({ cls: "body" }); this._viewport = new Viewport({ content: this._body }); if (!this._has_footer) { this.setStyleVariable("--footer-height", "0"); } // SCROLL this._viewport.addDOMEvent("scroll", (ev) => { // sync horz & vert elements this._left = this._viewport.dom.scrollLeft; this.setStyleVariable("--left", -this._left + "px"); this._top = this._viewport.dom.scrollTop; this.setStyleVariable("--top", -this._top + "px"); //this.setTimeout( "update", 0, ( ) => this._update( ) ); this._update() }); // WHEEL this.addDOMEvent("wheel", (ev: WheelEvent) => { if (ev.deltaY && this._store && this._store.getRowCount() >= SCROLL_LIMIT) { this._viewport.dom.scrollBy(0, ev.deltaY < 0 ? -1 : 1); ev.stopPropagation(); ev.preventDefault(); } if (this._has_fixed && ev.deltaY) { // wheel on fixed part // fixed part do not have scrollbar, so we need to handle it by hand let t = ev.target as Node; while (t != this.dom) { if (t == this._vheader.dom) { this._viewport.dom.scrollBy(0, ev.deltaY < 0 ? -this._row_height : this._row_height); ev.stopPropagation(); ev.preventDefault(); break; } t = t.parentNode; } } }) const targetCell = (e: MouseEvent) => { let el = e.target as Element; while (el && !el.classList.contains("cell")) { el = el.parentElement; } if (el) { const cel = componentFromDOM(el); return { ref: cel.getIntData("ref"), row: cel.getInternalData("row"), col: cel.getInternalData("col"), } } return undefined; } // CLICK this.addDOMEvent("click", (e) => { const ref = targetCell(e); if (ref) { //TODO: multiselection if (!this._selection.has(ref.ref)) { this._clearSelection( false ); this._addSelection(ref.ref,true); } } }); // DBLCLICK this.addDOMEvent("dblclick", (e) => { const ref = targetCell(e); if (ref) { //TODO: multiselection if (!this._selection.has(ref.ref)) { this._clearSelection(false); this._addSelection(ref.ref,true); } this._on_dblclk(e, ref.row, ref.col); debugger; //const rec = this._dataview.getByIndex( row ); //this.fire( "dblClick", { context: rec } ); } }); // CONTEXT this.addDOMEvent("contextmenu", (e) => { const ref = targetCell(e); if (ref) { //TODO: multiselection if (!this._selection.has(ref.ref)) { this._clearSelection( false ); this._addSelection(ref.ref, true ); } debugger; //const rec = this._dataview.getByIndex( row ); //this.fire( "contextMenu", { uievent: e, context: rec } ); } e.preventDefault(); e.stopPropagation(); }); this._updateFlexs(); this._fheader = this._buildColHeader(true); this._hheader = this._buildColHeader(false); this._vheader = new Box({ cls: "row-header" }) if (this._has_footer) { this._ffooter = this._buildColFooter(true); this._footer = this._buildColFooter(false); } this.setContent([this._viewport, this._fheader, this._hheader, this._ffooter, this._footer, this._vheader]); // compute misc variables { const rh = this.getStyleVariable("--row-height"); this._row_height = parseInt(rh); } this._computeFullSize(); } /** * */ protected _on_dblclk(e: UIEvent, row: number, col: number) { } /** * */ private _update(force = false) { if (!this._lock) { const rc = this.getBoundingRect(); // rows const rowc = this._store ? this._store.getRowCount() : 0; const mul = rowc < SCROLL_LIMIT ? this._row_height : 1; const start = Math.floor(this._top / mul); const end = start + Math.ceil(rc.height / this._row_height); const hasFixed = this._has_fixed; if (this._start != start || this._end != end || force) { const rows: Component[] = []; const headers: Component[] = []; if (force) { this._vis_rows.clear(); } let newvis: typeof this._vis_rows = new Map(); let y = start * mul; for (let row = start; row < end && row < rowc; row++, y += this._row_height) { let el = this._vis_rows.get(row); //const rec = this._store.getByIndex(row); if (hasFixed) { if (!el) { el = { h: this._buildRowHeader(row, y), r: this._buildRow(row, y), }; } else { el.h.setStyleValue("top", y + "px"); el.r.setStyleValue("top", y + "px"); } headers.push(el.h); } else { if (!el) { el = { h: null, r: this._buildRow(row, y), }; } else { el.r.setStyleValue("top", y + "px"); } } rows.push(el.r); newvis.set(row, el); } if (hasFixed) { headers.push(new Component({ cls: "cell-out", style: { top: y + "px" } })); } this._vis_rows = newvis; this._start = start; this._end = end; this._body.setContent(rows); if (hasFixed) { this._vheader.removeClass("@hidden"); this._vheader.setContent(headers); } else { this._vheader.addClass("@hidden"); } } } } /** * */ private _clearSelection(notify = true) { for (const ref of this._selection.keys()) { const els = this.queryAll(`.cell[data-ref="${ref}"]`) els.forEach(el => { el.removeClass("selected"); }) } this._selection.clear(); if (notify) { this.fire("selectionChange", { selection: [], empty: true }); } } /** * */ private _addSelection(ref: number, notify = true) { this._selection.add(ref) const els = this.queryAll(`.cell[data-ref="${ref}"]`) els.forEach(el => { el.addClass("selected"); }); if (notify) { const selection = this.getSelection(); this.fire("selectionChange", { selection, empty: selection.length != 0 }); } } /** * */ getSelection(): CellRef[] { const selection: CellRef[] = []; this._selection.forEach(x => { selection.push({ row: x >> 12, col: x & 0xfff, }) }); return selection; } /** * */ selectItem(row: number, col: number, append = false) { if (!append) { this._clearSelection(false); } this._addSelection(mkid(row, col), true); } }