UNPKG

@platform/ui.datagrid

Version:

Isolated tabular DataGrid.

508 lines (507 loc) 20 kB
import { Subject } from 'rxjs'; import { debounceTime, filter, map, share, takeUntil } from 'rxjs/operators'; import { commands } from '../../commands'; import { coord, defaultValue, MemoryCache, R, toSelectionValues, util, value as valueUtil, } from '../../common'; import { DEFAULT } from '../../common/constants'; import { keyboard } from '../../keyboard'; import { Cell } from '../Cell'; import { calc } from './Grid.calc'; export class Grid { constructor(args) { this._ = { id: '', totalColumns: -1, totalRows: -1, defaults: undefined, keyBindings: undefined, cache: MemoryCache.create(), table: undefined, dispose$: new Subject(), events$: new Subject(), redraw$: new Subject(), isReady: false, isEditing: false, ns: DEFAULT.NS, cells: {}, columns: {}, rows: {}, lastSelection: undefined, calc: undefined, refs: undefined, }; this.dispose$ = this._.dispose$.pipe(share()); this.events$ = this._.events$.pipe(takeUntil(this.dispose$), share()); this.keyboard$ = this._.events$.pipe(filter((e) => e.type === 'GRID/keydown'), map((e) => e.payload), share()); this.getValueSync = (key) => { const cell = this.data.cells[key]; return cell && typeof cell.value === 'string' ? cell.value : undefined; }; this.refsTable = coord.refs.table({ getKeys: async () => Object.keys(this.data.cells), getValue: async (key) => this.getValueSync(key), }); this.command = (args) => { const payload = { command: args.command, grid: this, selection: this.selection, props: args.props || {}, isCancelled: false, cancel: () => { payload.isCancelled = true; if (args.cancel) { args.cancel(); } }, }; this.fire({ type: 'GRID/command', payload }); return this; }; this.fire = (e) => { this._.events$.next(e); return this; }; this._init(args); } static create(args) { return new Grid(args); } static defaults(input) { const partial = input || {}; return { ns: defaultValue(partial.ns, DEFAULT.NS), totalColumns: defaultValue(partial.totalColumns, DEFAULT.TOTAL_COLUMNS), totalRows: defaultValue(partial.totalRows, DEFAULT.TOTAL_ROWS), columnWidth: defaultValue(partial.columnWidth, DEFAULT.COLUMN.WIDTH), columnWidthMin: defaultValue(partial.columnWidthMin, DEFAULT.COLUMN.WIDTH_MIN), rowHeight: defaultValue(partial.rowHeight, DEFAULT.ROW.HEIGHT), rowHeightMin: defaultValue(partial.rowHeightMin, DEFAULT.ROW.HEIGHT_MIN), }; } static toDataArray(args) { return Array.from({ length: args.totalRows }).map((v, row) => Array.from({ length: args.totalColumns }).map((v, column) => { return args.cells[Cell.toKey({ row, column })]; })); } static toNs(input) { const ns = input === undefined ? DEFAULT.NS : typeof input === 'string' ? { id: input } : input; ns.id = ns.id.trim().replace(/^ns\:/, ''); return ns; } static isDefaultValue(args) { const { kind, value } = args; const defaults = Grid.defaults(args.defaults); return util.isDefaultGridValue({ defaults, kind, value }); } _init(args) { const defaults = (this._.defaults = Grid.defaults(args.defaults)); this._.totalColumns = defaultValue(args.totalColumns, defaults.totalColumns); this._.totalRows = defaultValue(args.totalRows, defaults.totalRows); this._.ns = Grid.toNs(args.ns); this._.cells = args.cells || {}; this._.columns = args.columns || {}; this._.rows = args.rows || {}; this._.calc = calc({ grid: this, getFunc: args.getFunc }); this.events$ .pipe(filter((e) => e.type === 'GRID/ready')) .subscribe(() => (this._.isReady = true)); this._.keyBindings = R.uniqBy(R.prop('command'), [ ...(args.keyBindings || []), ...DEFAULT.KEY_BINDINGS, ]); const { table } = args; if (table) { this.initialize({ table }); } } initialize(args) { const { table } = args; this._.table = table; this._.id = `grid/${table.guid.replace(/^ht_/, '')}`; keyboard.init({ grid: this }); commands.init({ grid: this, fire: this.fire }); this._.redraw$ .pipe(takeUntil(this.dispose$), debounceTime(0)) .subscribe((e) => this.fire({ type: 'GRID/redraw', payload: {} })); this.events$ .pipe(filter((e) => e.type === 'GRID/EDITOR/begin')) .subscribe(() => (this._.isEditing = true)); const editEnd$ = this.events$.pipe(filter((e) => e.type === 'GRID/EDITOR/end'), map((e) => e.payload)); editEnd$.subscribe(() => (this._.isEditing = false)); editEnd$.pipe(filter((e) => e.isChanged)).subscribe((e) => { const key = e.cell.key; const value = e.value.to; const props = Object.assign(Object.assign({}, this.cell(key).data.props), { value: undefined }); const cell = { value, props }; this.changeCells({ [key]: cell }, { source: 'EDIT' }); }); editEnd$ .pipe(filter((e) => !e.isCancelled), filter((e) => e.cell.key === this.selection.cell)) .subscribe((e) => { const below = e.cell.siblings.bottom; if (below) { this.select({ cell: below }); } }); const selection$ = this.events$.pipe(filter((e) => e.type === 'GRID/selection'), map((e) => e.payload)); selection$ .pipe(filter((e) => Boolean(e.to.cell))) .subscribe((e) => (this._.lastSelection = e.to)); selection$ .pipe(debounceTime(0), filter((e) => !Boolean(e.from.cell) && Boolean(e.to.cell))) .subscribe((e) => this.fire({ type: 'GRID/focus', payload: { grid: this } })); selection$ .pipe(debounceTime(0), filter((e) => Boolean(e.from.cell) && !Boolean(e.to.cell))) .subscribe((e) => this.fire({ type: 'GRID/blur', payload: { grid: this } })); this.events$ .pipe(filter((e) => (this._.isReady = true)), filter((e) => e.type === 'GRID/cells/change'), map((e) => e.payload)) .subscribe(async (e) => { const cells = e.changes.map((change) => change.cell.key); await this.calc.update({ cells }); }); return this; } get id() { return this._.id; } get totalColumns() { return this._.totalColumns; } get totalRows() { return this._.totalRows; } get defaults() { return this._.defaults; } get keyBindings() { return this._.keyBindings; } get isInitialized() { return Boolean(this._.table); } get isDisposed() { return this._.table.isDestroyed || this._.dispose$.isStopped; } get isReady() { return this.isDisposed ? false : this._.isReady; } get isEditing() { return this._.isEditing; } get data() { const { ns, cells, columns, rows } = this._; const files = {}; return { ns, cells, columns, rows, files }; } setCells(cells) { cells = Object.assign({}, cells); const totalColumns = this.totalColumns; const totalRows = this.totalRows; const data = Grid.toDataArray({ cells, totalColumns, totalRows }); this._.cells = cells; this._.table.loadData(data); } get calc() { return this._.calc; } get selection() { const toKey = (coord) => Cell.toKey({ row: coord.row, column: coord.col }); const last = this._.table.getSelectedRangeLast(); const cell = last ? toKey(last.highlight) : undefined; const selectedRanges = this._.table.getSelectedRange() || []; let ranges = selectedRanges.map((item) => `${toKey(item.from)}:${toKey(item.to)}`); ranges = ranges.length === 1 && ranges[0] === `${cell}:${cell}` ? [] : ranges; let all = false; if (ranges.length > 0) { const min = { row: -1, col: -1 }; const max = { row: -1, col: -1 }; selectedRanges.forEach((range) => { const { from, to } = range; min.row = min.row === -1 || from.row < min.row ? from.row : min.row; min.col = min.col === -1 || from.col < min.col ? from.col : min.col; max.row = max.row === -1 || to.row > max.row ? to.row : max.row; max.col = max.col === -1 || to.col > max.col ? to.col : max.col; }); if (min.row === 0 && min.col === 0 && max.row === this.totalRows - 1 && max.col === this.totalColumns - 1) { all = true; } } if (ranges.length > 0) { const totalColumns = this.totalColumns; const totalRows = this.totalRows; const union = coord.range.union(ranges).formated({ totalColumns, totalRows }); ranges = union.ranges.map((range) => range.key); if (cell) { ranges = ranges.filter((range) => range !== `${cell}:${cell}`); } ranges = R.uniq(ranges); } let result = { cell, ranges }; result = all ? Object.assign(Object.assign({}, result), { all }) : result; return result; } get selectionValues() { const cells = this.data.cells; const selection = this.selection; return toSelectionValues({ cells, selection }); } dispose() { const { table, dispose$ } = this._; if (!table.isDestroyed) { table.destroy(); } dispose$.next(); dispose$.complete(); } mergeCells(args) { const { cells } = args; const list = args.init ? [] : this._.table.getSettings().mergeCells || []; const map = list.reduce((acc, next) => { const key = coord.cell.toKey(next.col, next.row); acc[key] = next; return acc; }, {}); Object.keys(cells).map((key) => { const item = cells[key]; if (item) { const props = item.props || {}; const merge = props.merge; if (merge) { const rowspan = Math.max(1, defaultValue(merge.rowspan, 1)); const colspan = Math.max(1, defaultValue(merge.colspan, 1)); const { row, column: col } = coord.cell.fromKey(key); map[key] = { row, col, rowspan, colspan }; } } }); const mergeCells = Object.keys(map).map((key) => map[key]); this._.table.updateSettings({ mergeCells }, false); return this; } changeCells(cells, options = {}) { const done = () => this; const format = (key, to) => { if (Cell.isEmpty(to)) { return undefined; } if (to) { to = Object.assign(Object.assign({}, to), { hash: util.gridCellHash(this, key, to) }); if (Cell.isEmptyProps(to.props)) { delete to.props; } } return to; }; if (options.init && cells && Object.keys(cells).length === 0) { this.setCells({}); return done(); } if (cells) { const current = Object.assign({}, (options.init ? {} : this.data.cells)); cells = Object.assign({}, cells); Object.keys(cells) .filter((key) => !coord.cell.isCell(key)) .forEach((key) => delete cells[key]); const formatted = Object.keys(cells).reduce((acc, key) => { acc[key] = { from: format(key, current[key]), to: format(key, cells[key]), }; return acc; }, {}); const changes = Object.keys(cells).map((key) => { const cell = this.cell(key); const { from, to } = formatted[key]; return Cell.changeEvent({ cell, from, to }); }); const isChanged = changes.some((e) => e.isChanged); if (!isChanged) { return done(); } if (!options.silent) { const payload = { source: defaultValue(options.source, 'EDIT'), changes, get isCancelled() { return changes.some((change) => change.isCancelled); }, cancel() { changes.forEach((change) => change.cancel()); }, }; this.fire({ type: 'GRID/cells/change', payload }); changes .filter((change) => change.isModified) .forEach((change) => (cells[change.cell.key] = change.value.to)); changes .filter((change) => change.isCancelled) .forEach((change) => (cells[change.cell.key] = change.value.from)); } const mergeChanges = {}; const updates = Object.assign(Object.assign({}, current), Object.keys(formatted).reduce((acc, key) => { acc[key] = formatted[key].to; return acc; }, {})); Object.keys(formatted).forEach((key) => { const { from, to } = formatted[key]; if (Cell.isEmpty(to)) { delete updates[key]; return; } const isMergeChanged = !R.equals(((from || {}).props || {}).merge, ((to || {}).props || {}).merge); if (isMergeChanged) { mergeChanges[key] = to; } }); if (Object.keys(mergeChanges).length > 0) { this.mergeCells({ cells: mergeChanges }); } this.setCells(updates); } return done(); } changeColumns(columns, options = {}) { const { source = 'UPDATE' } = options; const from = Object.assign({}, this._.columns); const to = Object.assign({}, from); let changes = []; Object.keys(columns).forEach((key) => { const prev = from[key] || { props: { grid: { width: -1 } } }; const next = columns[key] || { props: { grid: { width: this.defaults.columnWidth } } }; const nextProps = next.props || {}; const isDefault = nextProps.grid && nextProps.grid.width === this.defaults.columnWidth; if (isDefault) { delete to[key]; } else { to[key] = next; } if (!R.equals(prev, next)) { changes = [...changes, { column: key, source, from: prev, to: next }]; } }); this._.columns = to; if (!R.equals(from, to)) { this.fire({ type: 'GRID/columns/change', payload: { from, to, changes } }); } return this; } changeRows(rows, options = {}) { const { source = 'UPDATE' } = options; const from = Object.assign({}, this._.rows); const to = Object.assign({}, from); let changes = []; Object.keys(rows).forEach((key) => { const prev = from[key] || { props: { grid: { height: -1 } } }; const next = rows[key] || { props: { grid: { height: this.defaults.rowHeight } } }; const nextProps = next.props || {}; const isDefault = nextProps.grid && nextProps.grid.height === this.defaults.rowHeight; if (isDefault) { delete to[key]; } else { to[key] = next; } if (!R.equals(prev, next)) { const row = coord.cell.fromKey(key).row; changes = [...changes, { row, source, from: prev, to: next }]; } }); this._.rows = to; if (!R.equals(from, to)) { this.fire({ type: 'GRID/rows/change', payload: { from, to, changes }, }); } return this; } cell(key) { const args = typeof key === 'string' ? Cell.fromKey(key) : key; const { row, column } = args; if (row < 0 || column < 0) { let msg = `Cell does not exist at row:${row}, column:${column}.`; msg = typeof key === 'string' ? `${msg} key: "${key}"` : msg; throw new Error(msg); } const ns = this.data.ns.id; const cacheKey = `${ns}:cell/${column}:${row}`; return this._.cache.get(cacheKey, () => { const table = this._.table; return Cell.create({ ns, table, row, column }); }); } scrollTo(args) { const { row, column } = this.toPosition(args.cell); const { snapToBottom = false, snapToRight = false } = args; this._.table.scrollViewportTo(row, column, snapToBottom, snapToRight); return this; } select(args) { const totalColumns = this.totalColumns; const totalRows = this.totalRows; const table = this._.table; const scrollToCell = valueUtil.defaultValue(args.scrollToCell, true); const ranges = (args.ranges || []) .map((range) => Cell.toRangePositions({ range, totalColumns, totalRows })) .map(({ start, end }) => { return [start.row, start.column, end.row, end.column]; }); const pos = this.toPosition(args.cell); const current = [pos.row, pos.column, pos.row, pos.column]; const selection = [...ranges, current]; table.selectCells(selection, scrollToCell); return this; } deselect() { this._.table.deselectCell(); return this; } focus() { const last = this._.lastSelection; const cell = (last && last.cell) || 'A1'; const ranges = (last && last.ranges) || []; this.select({ cell, ranges }); return this; } blur() { this.fire({ type: 'GRID/blur', payload: { grid: this } }); this.deselect(); return this; } redraw() { this._.redraw$.next(); return this; } toPosition(ref) { const pos = Cell.toPosition(ref); const row = R.clamp(0, this.totalRows - 1, pos.row); const column = R.clamp(0, this.totalColumns - 1, pos.column); return { row, column }; } updateHashes(options = {}) { const data = this.data; const cells = Object.assign({}, data.cells); let isChanged = false; Object.keys(cells).forEach((key) => { const cell = cells[key]; if (cell) { let hash = cell.hash; if (!hash || options.force) { hash = util.gridCellHash(this, key, cell); cells[key] = Object.assign(Object.assign({}, cell), { hash }); isChanged = true; } } }); if (isChanged) { this.setCells(cells); } return this; } }