UNPKG

frappe-datatable

Version:

A modern datatable library for the web

935 lines (760 loc) 28.5 kB
import { copyTextToClipboard, makeDataAttributeString, throttle, linkProperties, escapeHTML, } from './utils'; import $ from './dom'; import icons from './icons'; export default class CellManager { constructor(instance) { this.instance = instance; linkProperties(this, this.instance, [ 'wrapper', 'options', 'style', 'header', 'bodyScrollable', 'columnmanager', 'rowmanager', 'datamanager', 'keyboard', 'footer' ]); this.bindEvents(); } bindEvents() { this.bindFocusCell(); this.bindEditCell(); this.bindKeyboardSelection(); this.bindCopyCellContents(); this.bindMouseEvents(); this.bindTreeEvents(); } bindFocusCell() { this.bindKeyboardNav(); } bindEditCell() { this.$editingCell = null; $.on(this.bodyScrollable, 'dblclick', '.dt-cell', (e, cell) => { this.activateEditing(cell); }); this.keyboard.on('enter', () => { if (this.$focusedCell && !this.$editingCell) { // enter keypress on focused cell this.activateEditing(this.$focusedCell); } else if (this.$editingCell) { // enter keypress on editing cell this.deactivateEditing(); } }); } bindKeyboardNav() { const focusLastCell = (direction) => { if (!this.$focusedCell || this.$editingCell) { return false; } let $cell = this.$focusedCell; const { rowIndex, colIndex } = $.data($cell); if (direction === 'left') { $cell = this.getLeftMostCell$(rowIndex); } else if (direction === 'right') { $cell = this.getRightMostCell$(rowIndex); } else if (direction === 'up') { $cell = this.getTopMostCell$(colIndex); } else if (direction === 'down') { $cell = this.getBottomMostCell$(colIndex); } this.focusCell($cell); return true; }; ['left', 'right', 'up', 'down', 'tab', 'shift+tab'] .map(direction => this.keyboard.on(direction, () => this.focusCellInDirection(direction))); ['left', 'right', 'up', 'down'] .map(direction => this.keyboard.on(`ctrl+${direction}`, () => focusLastCell(direction))); this.keyboard.on('esc', () => { this.deactivateEditing(false); this.columnmanager.toggleFilter(false); }); if (this.options.inlineFilters) { this.keyboard.on('ctrl+f', (e) => { const $cell = $.closest('.dt-cell', e.target); const { colIndex } = $.data($cell); this.activateFilter(colIndex); return true; }); $.on(this.header, 'focusin', '.dt-filter', () => { this.unfocusCell(this.$focusedCell); }); } } bindKeyboardSelection() { const getNextSelectionCursor = (direction) => { let $selectionCursor = this.getSelectionCursor(); if (direction === 'left') { $selectionCursor = this.getLeftCell$($selectionCursor); } else if (direction === 'right') { $selectionCursor = this.getRightCell$($selectionCursor); } else if (direction === 'up') { $selectionCursor = this.getAboveCell$($selectionCursor); } else if (direction === 'down') { $selectionCursor = this.getBelowCell$($selectionCursor); } return $selectionCursor; }; ['left', 'right', 'up', 'down'] .map(direction => this.keyboard.on(`shift+${direction}`, () => this.selectArea(getNextSelectionCursor(direction)))); } bindCopyCellContents() { this.keyboard.on('ctrl+c', () => { const noOfCellsCopied = this.copyCellContents(this.$focusedCell, this.$selectionCursor); const message = this.instance.translate('{count} cells copied', { count: noOfCellsCopied }); if (noOfCellsCopied) { this.instance.showToastMessage(message, 2); } }); if (this.options.pasteFromClipboard) { this.keyboard.on('ctrl+v', (e) => { // hack // https://stackoverflow.com/a/2177059/5353542 this.instance.pasteTarget.focus(); setTimeout(() => { const data = this.instance.pasteTarget.value; this.instance.pasteTarget.value = ''; this.pasteContentInCell(data); }, 10); return false; }); } } bindMouseEvents() { let mouseDown = null; $.on(this.bodyScrollable, 'mousedown', '.dt-cell', (e) => { mouseDown = true; this.focusCell($(e.delegatedTarget)); }); $.on(this.bodyScrollable, 'mouseup', () => { mouseDown = false; }); if (this.options.showTotalRow) { $.on(this.footer, 'click', '.dt-cell', (e) => { this.focusCell($(e.delegatedTarget)); }); } const selectArea = (e) => { if (!mouseDown) return; this.selectArea($(e.delegatedTarget)); }; $.on(this.bodyScrollable, 'mousemove', '.dt-cell', throttle(selectArea, 50)); } bindTreeEvents() { $.on(this.bodyScrollable, 'click', '.dt-tree-node__toggle', (e, $toggle) => { const $cell = $.closest('.dt-cell', $toggle); const { rowIndex } = $.data($cell); if ($cell.classList.contains('dt-cell--tree-close')) { this.rowmanager.openSingleNode(rowIndex); } else { this.rowmanager.closeSingleNode(rowIndex); } }); } focusCell($cell, { skipClearSelection = 0, skipDOMFocus = 0, skipScrollToCell = 0 } = {}) { if (!$cell) return; // don't focus if already editing cell if ($cell === this.$editingCell) return; const { colIndex, isHeader } = $.data($cell); if (isHeader) { return; } const column = this.columnmanager.getColumn(colIndex); if (column.focusable === false) { return; } if (!skipScrollToCell) { this.scrollToCell($cell); } this.deactivateEditing(); if (!skipClearSelection) { this.clearSelection(); } if (this.$focusedCell) { this.$focusedCell.classList.remove('dt-cell--focus'); } this.$focusedCell = $cell; $cell.classList.add('dt-cell--focus'); if (!skipDOMFocus) { // so that keyboard nav works $cell.focus(); } this.highlightRowColumnHeader($cell); } unfocusCell($cell) { if (!$cell) return; // remove cell border $cell.classList.remove('dt-cell--focus'); this.$focusedCell = null; // reset header background if (this.lastHeaders) { this.lastHeaders.forEach(header => header && header.classList.remove('dt-cell--highlight')); } } highlightRowColumnHeader($cell) { const { colIndex, rowIndex } = $.data($cell); const srNoColIndex = this.datamanager.getColumnIndexById('_rowIndex'); const colHeaderSelector = `.dt-cell--header-${colIndex}`; const rowHeaderSelector = `.dt-cell--${srNoColIndex}-${rowIndex}`; if (this.lastHeaders) { this.lastHeaders.forEach(header => header && header.classList.remove('dt-cell--highlight')); } const colHeader = $(colHeaderSelector, this.wrapper); const rowHeader = $(rowHeaderSelector, this.wrapper); this.lastHeaders = [colHeader, rowHeader]; this.lastHeaders.forEach(header => header && header.classList.add('dt-cell--highlight')); } selectAreaOnClusterChanged() { if (!(this.$focusedCell && this.$selectionCursor)) return; const { colIndex, rowIndex } = $.data(this.$selectionCursor); const $cell = this.getCell$(colIndex, rowIndex); if (!$cell || $cell === this.$selectionCursor) return; // selectArea needs $focusedCell const fCell = $.data(this.$focusedCell); this.$focusedCell = this.getCell$(fCell.colIndex, fCell.rowIndex); this.selectArea($cell); } focusCellOnClusterChanged() { if (!this.$focusedCell) return; const { colIndex, rowIndex } = $.data(this.$focusedCell); const $cell = this.getCell$(colIndex, rowIndex); if (!$cell) return; // this function is called after hyperlist renders the rows after scroll, // focusCell calls clearSelection which resets the area selection // so a flag to skip it // we also skip DOM focus and scroll to cell // because it fights with the user scroll this.focusCell($cell, { skipClearSelection: 1, skipDOMFocus: 1, skipScrollToCell: 1 }); } selectArea($selectionCursor) { if (!this.$focusedCell) return; if (this._selectArea(this.$focusedCell, $selectionCursor)) { // valid selection this.$selectionCursor = $selectionCursor; } } _selectArea($cell1, $cell2) { if ($cell1 === $cell2) return false; const cells = this.getCellsInRange($cell1, $cell2); if (!cells) return false; this.clearSelection(); this._selectedCells = cells.map(index => this.getCell$(...index)); requestAnimationFrame(() => { this._selectedCells.map($cell => $cell.classList.add('dt-cell--highlight')); }); return true; } getCellsInRange($cell1, $cell2) { let colIndex1, rowIndex1, colIndex2, rowIndex2; if (typeof $cell1 === 'number') { [colIndex1, rowIndex1, colIndex2, rowIndex2] = arguments; } else if (typeof $cell1 === 'object') { if (!($cell1 && $cell2)) { return false; } const cell1 = $.data($cell1); const cell2 = $.data($cell2); colIndex1 = +cell1.colIndex; colIndex2 = +cell2.colIndex; if (this.columnmanager.sortState) { this.sortedColumn = true; rowIndex1 = this.datamanager.rowViewOrder.indexOf(parseInt(cell1.rowIndex, 10)); rowIndex2 = this.datamanager.rowViewOrder.indexOf(parseInt(cell2.rowIndex, 10)); } else { rowIndex1 = +cell1.rowIndex; rowIndex2 = +cell2.rowIndex; } } if (rowIndex1 > rowIndex2) { [rowIndex1, rowIndex2] = [rowIndex2, rowIndex1]; } if (colIndex1 > colIndex2) { [colIndex1, colIndex2] = [colIndex2, colIndex1]; } if (this.isStandardCell(colIndex1) || this.isStandardCell(colIndex2)) { return false; } const cells = []; let colIndex = colIndex1; let rowIndex = rowIndex1; const rowIndices = []; while (rowIndex <= rowIndex2) { rowIndices.push(rowIndex); rowIndex += 1; } rowIndices.map((rowIndex) => { while (colIndex <= colIndex2) { cells.push([colIndex, rowIndex]); colIndex++; } colIndex = colIndex1; }); if (this.columnmanager.sortState) { cells.forEach(selectedCells => { selectedCells[1] = this.datamanager.rowViewOrder[selectedCells[1]]; }); } return cells; } clearSelection() { (this._selectedCells || []) .forEach($cell => $cell.classList.remove('dt-cell--highlight')); this._selectedCells = []; this.$selectionCursor = null; } getSelectionCursor() { return this.$selectionCursor || this.$focusedCell; } activateEditing($cell) { this.focusCell($cell); const { rowIndex, colIndex } = $.data($cell); const col = this.columnmanager.getColumn(colIndex); if (col && (col.editable === false || col.focusable === false)) { return; } const cell = this.getCell(colIndex, rowIndex); if (cell && cell.editable === false) { return; } if (this.$editingCell) { const { _rowIndex, _colIndex } = $.data(this.$editingCell); if (rowIndex === _rowIndex && colIndex === _colIndex) { // editing the same cell return; } } this.$editingCell = $cell; $cell.classList.add('dt-cell--editing'); const $editCell = $('.dt-cell__edit', $cell); $editCell.innerHTML = ''; const editor = this.getEditor(colIndex, rowIndex, cell.content, $editCell); if (editor) { this.currentCellEditor = editor; // initialize editing input with cell value editor.initValue(cell.content, rowIndex, col); } } deactivateEditing(submitValue = true) { if (submitValue) { this.submitEditing(); } // keep focus on the cell so that keyboard navigation works if (this.$focusedCell) this.$focusedCell.focus(); if (!this.$editingCell) return; this.$editingCell.classList.remove('dt-cell--editing'); this.$editingCell = null; } getEditor(colIndex, rowIndex, value, parent) { const column = this.datamanager.getColumn(colIndex); const row = this.datamanager.getRow(rowIndex); const data = this.datamanager.getData(rowIndex); let editor = this.options.getEditor ? this.options.getEditor(colIndex, rowIndex, value, parent, column, row, data) : this.getDefaultEditor(parent); if (editor === false) { // explicitly returned false return false; } if (editor === undefined) { // didn't return editor, fallback to default editor = this.getDefaultEditor(parent); } return editor; } getDefaultEditor(parent) { const $input = $.create('input', { class: 'dt-input', type: 'text', inside: parent }); return { initValue(value) { $input.focus(); $input.value = value; }, getValue() { return $input.value; }, setValue(value) { $input.value = value; } }; } submitEditing() { let promise = Promise.resolve(); if (!this.$editingCell) return promise; const $cell = this.$editingCell; const { rowIndex, colIndex } = $.data($cell); const col = this.datamanager.getColumn(colIndex); if ($cell) { const editor = this.currentCellEditor; if (editor) { let valuePromise = editor.getValue(); // convert to stubbed Promise if (!valuePromise.then) { valuePromise = Promise.resolve(valuePromise); } promise = valuePromise.then((value) => { const oldValue = this.getCell(colIndex, rowIndex).content; if (oldValue === value) return false; const done = editor.setValue(value, rowIndex, col); // update cell immediately this.updateCell(colIndex, rowIndex, value, true); $cell.focus(); if (done && done.then) { // revert to oldValue if promise fails done.catch((e) => { console.log(e); this.updateCell(colIndex, rowIndex, oldValue); }); } return done; }); } } this.currentCellEditor = null; return promise; } copyCellContents($cell1, $cell2) { if (!$cell2 && $cell1) { // copy only focusedCell const { colIndex, rowIndex, isTotalRow } = $.data($cell1); let copiedContent = ''; if (isTotalRow) { let choosenFooterCell = this.$focusedCell; copiedContent = choosenFooterCell.children[0].title; } else { const cell = this.getCell(colIndex, rowIndex); copiedContent = cell.content; } copyTextToClipboard(copiedContent); return 1; } const cells = this.getCellsInRange($cell1, $cell2); if (!cells) return 0; const rows = cells // get cell objects .map(index => this.getCell(...index)) // convert to array of rows .reduce((acc, curr) => { const rowIndex = curr.rowIndex; acc[rowIndex] = acc[rowIndex] || []; acc[rowIndex].push(curr.content); return acc; }, []); const values = rows // join values by tab .map(row => row.join('\t')) // join rows by newline .join('\n'); copyTextToClipboard(values); // return no of cells copied return rows.reduce((total, row) => total + row.length, 0); } pasteContentInCell(data) { if (!this.$focusedCell) return; const matrix = data .split('\n') .map(row => row.split('\t')) .filter(row => row.length && row.every(it => it)); let { colIndex, rowIndex } = $.data(this.$focusedCell); let focusedCell = { colIndex: +colIndex, rowIndex: +rowIndex }; matrix.forEach((row, i) => { let rowIndex = i + focusedCell.rowIndex; row.forEach((cell, j) => { let colIndex = j + focusedCell.colIndex; this.updateCell(colIndex, rowIndex, cell, true); }); }); } activateFilter(colIndex) { this.columnmanager.toggleFilter(); this.columnmanager.focusFilter(colIndex); if (!this.columnmanager.isFilterShown) { // put focus back on cell this.$focusedCell && this.$focusedCell.focus(); } } updateCell(colIndex, rowIndex, value, refreshHtml = false) { const cell = this.datamanager.updateCell(colIndex, rowIndex, { content: value }); this.refreshCell(cell, refreshHtml); } refreshCell(cell, refreshHtml = false) { const $cell = $(this.selector(cell.colIndex, cell.rowIndex), this.bodyScrollable); $cell.innerHTML = this.getCellContent(cell, refreshHtml); } toggleTreeButton(rowIndex, flag) { const colIndex = this.columnmanager.getFirstColumnIndex(); const $cell = this.getCell$(colIndex, rowIndex); if ($cell) { $cell.classList[flag ? 'remove' : 'add']('dt-cell--tree-close'); } } isStandardCell(colIndex) { // Standard cells are in Sr. No and Checkbox column return colIndex < this.columnmanager.getFirstColumnIndex(); } focusCellInDirection(direction) { if (!this.$focusedCell || (this.$editingCell && ['left', 'right', 'up', 'down'].includes(direction))) { return false; } else if (this.$editingCell && ['tab', 'shift+tab'].includes(direction)) { this.deactivateEditing(); } let $cell = this.$focusedCell; if (direction === 'left' || direction === 'shift+tab') { $cell = this.getLeftCell$($cell); } else if (direction === 'right' || direction === 'tab') { $cell = this.getRightCell$($cell); } else if (direction === 'up') { $cell = this.getAboveCell$($cell); } else if (direction === 'down') { $cell = this.getBelowCell$($cell); } if (!$cell) { return false; } const { colIndex } = $.data($cell); const column = this.columnmanager.getColumn(colIndex); if (!column.focusable) { let $prevFocusedCell = this.$focusedCell; this.unfocusCell($prevFocusedCell); this.$focusedCell = $cell; let ret = this.focusCellInDirection(direction); if (!ret) { this.focusCell($prevFocusedCell); } return ret; } this.focusCell($cell); return true; } getCell$(colIndex, rowIndex) { return $(this.selector(colIndex, rowIndex), this.bodyScrollable); } getAboveCell$($cell) { const { colIndex } = $.data($cell); let $aboveRow = $cell.parentElement.previousElementSibling; while ($aboveRow && $aboveRow.classList.contains('dt-row--hide')) { $aboveRow = $aboveRow.previousElementSibling; } if (!$aboveRow) return $cell; return $(`.dt-cell--col-${colIndex}`, $aboveRow); } getBelowCell$($cell) { const { colIndex } = $.data($cell); let $belowRow = $cell.parentElement.nextElementSibling; while ($belowRow && $belowRow.classList.contains('dt-row--hide')) { $belowRow = $belowRow.nextElementSibling; } if (!$belowRow) return $cell; return $(`.dt-cell--col-${colIndex}`, $belowRow); } getLeftCell$($cell) { return $cell.previousElementSibling; } getRightCell$($cell) { return $cell.nextElementSibling; } getLeftMostCell$(rowIndex) { return this.getCell$(this.columnmanager.getFirstColumnIndex(), rowIndex); } getRightMostCell$(rowIndex) { return this.getCell$(this.columnmanager.getLastColumnIndex(), rowIndex); } getTopMostCell$(colIndex) { return this.getCell$(colIndex, this.rowmanager.getFirstRowIndex()); } getBottomMostCell$(colIndex) { return this.getCell$(colIndex, this.rowmanager.getLastRowIndex()); } getCell(colIndex, rowIndex) { return this.instance.datamanager.getCell(colIndex, rowIndex); } getRowHeight() { return $.style($('.dt-row', this.bodyScrollable), 'height'); } scrollToCell($cell) { if ($.inViewport($cell, this.bodyScrollable) || $.inViewport($cell, this.footer)) return false; const { rowIndex } = $.data($cell); this.rowmanager.scrollToRow(rowIndex); return false; } getRowCountPerPage() { return Math.ceil(this.instance.getViewportHeight() / this.getRowHeight()); } getCellHTML(cell) { const { rowIndex, colIndex, isHeader, isFilter, isTotalRow } = cell; const dataAttr = makeDataAttributeString({ rowIndex, colIndex, isHeader, isFilter, isTotalRow }); const row = this.datamanager.getRow(rowIndex); const isBodyCell = !(isHeader || isFilter || isTotalRow); const className = [ 'dt-cell', 'dt-cell--col-' + colIndex, isBodyCell ? `dt-cell--${colIndex}-${rowIndex}` : '', isBodyCell ? 'dt-cell--row-' + rowIndex : '', isHeader ? 'dt-cell--header' : '', isHeader ? `dt-cell--header-${colIndex}` : '', isFilter ? 'dt-cell--filter' : '', isBodyCell && (row && row.meta.isTreeNodeClose) ? 'dt-cell--tree-close' : '' ].join(' '); return ` <div class="${className}" ${dataAttr} tabindex="0"> ${this.getCellContent(cell)} </div> `; } getCellContent(cell, refreshHtml = false) { const { isHeader, isFilter, colIndex } = cell; const editable = !isHeader && cell.editable !== false; const editCellHTML = editable ? this.getEditCellHTML(colIndex) : ''; const sortable = isHeader && cell.sortable !== false; const sortIndicator = sortable ? `<span class="sort-indicator"> ${this.options.sortIndicator[cell.sortOrder]} </span>` : ''; const resizable = isHeader && cell.resizable !== false; const resizeColumn = resizable ? '<span class="dt-cell__resize-handle"></span>' : ''; const hasDropdown = isHeader && cell.dropdown !== false; const dropdown = hasDropdown ? this.columnmanager.getDropdownHTML() : ''; let customFormatter = CellManager.getCustomCellFormatter(cell); let contentHTML; if (isHeader || isFilter || !customFormatter) { contentHTML = cell.content; } else { if (!cell.html || refreshHtml) { const row = this.datamanager.getRow(cell.rowIndex); const data = this.datamanager.getData(cell.rowIndex); contentHTML = customFormatter(cell.content, row, cell.column, data); } else { contentHTML = cell.html; } } cell.html = contentHTML; if (this.options.treeView && !(isHeader || isFilter) && cell.indent !== undefined) { const nextRow = this.datamanager.getRow(cell.rowIndex + 1); const addToggle = nextRow && nextRow.meta.indent > cell.indent; const leftPadding = 20; const unit = 'px'; // Add toggle and indent in the first column const firstColumnIndex = this.datamanager.getColumnIndexById('_rowIndex') + 1; if (firstColumnIndex === cell.colIndex) { const padding = ((cell.indent || 0)) * leftPadding; const toggleHTML = addToggle ? `<span class="dt-tree-node__toggle" style="left: ${padding - leftPadding}${unit}"> <span class="icon-open">${icons.chevronDown}</span> <span class="icon-close">${icons.chevronRight}</span> </span>` : ''; contentHTML = `<span class="dt-tree-node" style="padding-left: ${padding}${unit}"> ${toggleHTML} <span>${contentHTML}</span> </span>`; } } const className = [ 'dt-cell__content', isHeader ? `dt-cell__content--header-${colIndex}` : `dt-cell__content--col-${colIndex}` ].join(' '); let cellContentHTML = ` <div class="${className}"> ${contentHTML} ${sortIndicator} ${resizeColumn} ${dropdown} </div> ${editCellHTML} `; let div = document.createElement('div'); div.innerHTML = contentHTML; let textContent = div.textContent; textContent = textContent.replace(/\s+/g, ' ').trim(); cellContentHTML = cellContentHTML.replace('>', ` title="${escapeHTML(textContent)}">`); return cellContentHTML; } getEditCellHTML(colIndex) { return `<div class="dt-cell__edit dt-cell__edit--col-${colIndex}"></div>`; } selector(colIndex, rowIndex) { return `.dt-cell--${colIndex}-${rowIndex}`; } static getCustomCellFormatter(cell) { return cell.format || (cell.column && cell.column.format) || null; } }