UNPKG

handsontable

Version:

Handsontable is a JavaScript Data Grid available for React, Angular and Vue.

353 lines (345 loc) • 13.1 kB
"use strict"; exports.__esModule = true; exports._dataToHTML = _dataToHTML; exports.htmlToGridSettings = htmlToGridSettings; exports.instanceToHTML = instanceToHTML; exports.replaceTdCellsWithTextContent = replaceTdCellsWithTextContent; var _mixed = require("./../helpers/mixed"); const ESCAPED_HTML_CHARS = { '&nbsp;': '\x20', '&amp;': '&', '&lt;': '<', '&gt;': '>' }; const regEscapedChars = new RegExp(Object.keys(ESCAPED_HTML_CHARS).map(key => `(${key})`).join('|'), 'gi'); /** * Verifies if node is an HTMLTable element. * * @param {Node} element Node to verify if it's an HTMLTable. * @returns {boolean} */ function isHTMLTable(element) { return (element && element.nodeName || '') === 'TABLE'; } /** * Converts Handsontable into HTMLTableElement. * * @param {Core} instance The Handsontable instance. * @returns {string} OuterHTML of the HTMLTableElement. */ function instanceToHTML(instance) { const hasColumnHeaders = instance.hasColHeaders(); const hasRowHeaders = instance.hasRowHeaders(); const coords = [hasColumnHeaders ? -1 : 0, hasRowHeaders ? -1 : 0, instance.countRows() - 1, instance.countCols() - 1]; const data = instance.getData(...coords); const countRows = data.length; const countCols = countRows > 0 ? data[0].length : 0; const TABLE = ['<table>', '</table>']; const THEAD = hasColumnHeaders ? ['<thead>', '</thead>'] : []; const TBODY = ['<tbody>', '</tbody>']; const rowModifier = hasRowHeaders ? 1 : 0; const columnModifier = hasColumnHeaders ? 1 : 0; for (let row = 0; row < countRows; row += 1) { const isColumnHeadersRow = hasColumnHeaders && row === 0; const CELLS = []; for (let column = 0; column < countCols; column += 1) { const isRowHeadersColumn = !isColumnHeadersRow && hasRowHeaders && column === 0; let cell = ''; if (isColumnHeadersRow) { cell = `<th>${instance.getColHeader(column - rowModifier)}</th>`; } else if (isRowHeadersColumn) { cell = `<th>${instance.getRowHeader(row - columnModifier)}</th>`; } else { const cellData = data[row][column]; const { hidden, rowspan, colspan } = instance.getCellMeta(row - columnModifier, column - rowModifier); if (!hidden) { const attrs = []; if (rowspan) { attrs.push(`rowspan="${rowspan}"`); } if (colspan) { attrs.push(`colspan="${colspan}"`); } if ((0, _mixed.isEmpty)(cellData)) { cell = `<td ${attrs.join(' ')}></td>`; } else { const value = cellData.toString().replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/(<br(\s*|\/)>(\r\n|\n)?|\r\n|\n)/g, '<br>\r\n').replace(/\x20/gi, '&nbsp;').replace(/\t/gi, '&#9;'); cell = `<td ${attrs.join(' ')}>${value}</td>`; } } } CELLS.push(cell); } const TR = ['<tr>', ...CELLS, '</tr>'].join(''); if (isColumnHeadersRow) { THEAD.splice(1, 0, TR); } else { TBODY.splice(-1, 0, TR); } } TABLE.splice(1, 0, THEAD.join(''), TBODY.join('')); return TABLE.join(''); } /** * Converts 2D array into HTMLTableElement. * * @param {Array} input Input array which will be converted to HTMLTable. * @returns {string} OuterHTML of the HTMLTableElement. */ // eslint-disable-next-line no-restricted-globals function _dataToHTML(input) { const inputLen = input.length; const result = ['<table>']; for (let row = 0; row < inputLen; row += 1) { const rowData = input[row]; const columnsLen = rowData.length; const columnsResult = []; if (row === 0) { result.push('<tbody>'); } for (let column = 0; column < columnsLen; column += 1) { const cellData = rowData[column]; const parsedCellData = (0, _mixed.isEmpty)(cellData) ? '' : cellData.toString().replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/(<br(\s*|\/)>(\r\n|\n)?|\r\n|\n)/g, '<br>\r\n').replace(/\x20{2,}/gi, substring => { // The way how Excel serializes data with at least two spaces. return `<span style="mso-spacerun: yes">${'&nbsp;'.repeat(substring.length - 1)} </span>`; }).replace(/\t/gi, '&#9;'); columnsResult.push(`<td>${parsedCellData}</td>`); } result.push('<tr>', ...columnsResult, '</tr>'); if (row + 1 === inputLen) { result.push('</tbody>'); } } result.push('</table>'); return result.join(''); } const TD_OPEN = /<td\b[^>]*>/i; const TD_CLOSE = /<\/\s*td\s*>/i; const paragraphRegexp = /<p.*?>/g; /** * Finds the closing `</td>` that matches the first `<td...>` in `html` at `openEndIndex`. * Handles nested `<td>...</td>` (e.g. Excel cells with shapes that contain inner tables). * * @param {string} html Full HTML string. * @param {number} openEndIndex Index right after the opening `<td...>` (after the `>`). * @returns {{ start: number, length: number }|null} Start index and length of the matching `</td>`, or null. */ function findMatchingTdClose(html, openEndIndex) { let depth = 1; let searchStart = openEndIndex; while (depth > 0) { const tail = html.substring(searchStart); const openMatch = tail.match(TD_OPEN); const closeMatch = tail.match(TD_CLOSE); if (!closeMatch) { return null; } const closeIndex = searchStart + closeMatch.index; const closeTagLength = closeMatch[0].length; const openIndex = openMatch ? searchStart + openMatch.index : html.length; if (openIndex < closeIndex) { depth += 1; searchStart = openIndex + openMatch[0].length; } else { depth -= 1; if (depth === 0) { return { start: closeIndex, length: closeTagLength }; } searchStart = closeIndex + closeTagLength; } } return null; } /** * Replaces each `<td>...</td>` in the HTML string with a normalized version that keeps only * text-like content (strips nested tables, VML/shapes, etc.). Uses matching close-tag search * so that nested `<td>` (e.g. from Excel paste with shapes) do not truncate the payload. * Exported so clipboard HTML can be normalized before sanitization. * * @param {string} html Raw HTML (e.g. from clipboard text/html). * @returns {string} HTML with each cell replaced by opening tag + stripped text + `</td>`. */ function replaceTdCellsWithTextContent(html) { const result = []; let pos = 0; while (pos < html.length) { const openMatch = html.substring(pos).match(TD_OPEN); if (!openMatch) { result.push(html.substring(pos)); break; } const openStart = pos + openMatch.index; const openTag = openMatch[0]; const openEnd = openStart + openTag.length; result.push(html.substring(pos, openStart)); const closeInfo = findMatchingTdClose(html, openEnd); if (!closeInfo) { // Malformed HTML (no matching </td>): leave rest as-is to avoid truncation. result.push(html.substring(openStart)); break; } const cellFragment = html.substring(openStart, closeInfo.start + closeInfo.length); const contentEnd = closeInfo.start - openStart; const rawContent = cellFragment.substring(openTag.length, contentEnd).trim().replaceAll(/\n\s+/g, ' ') // HTML tags may be split using multiple new lines and whitespaces .replaceAll(paragraphRegexp, '\n') // Only paragraphs should split text using new line characters .replace(/^\n+/, '') // First paragraph shouldn't start with new line characters .replaceAll(/<\/(.*)>\s+$/mg, '</$1>') // HTML tags may end with whitespace. .replace(/(<(?!br)([^>]+)>)/gi, '') // Removing HTML tags .replaceAll(/^&nbsp;$/mg, ''); // Removing single &nbsp; characters separating new lines result.push(`${openTag}${rawContent}</td>`); pos = closeInfo.start + closeInfo.length; } return result.join(''); } /** * Converts HTMLTable or string into Handsontable configuration object. * * @param {Element|string} element Node element which should contain `<table>...</table>`. * @param {Document} [rootDocument] The document window owner. * @returns {object} Return configuration object. Contains keys as DefaultSettings. */ // eslint-disable-next-line no-restricted-globals function htmlToGridSettings(element) { let rootDocument = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : document; const settingsObj = {}; const fragment = rootDocument.createDocumentFragment(); const tempElem = rootDocument.createElement('div'); fragment.appendChild(tempElem); let checkElement = element; if (typeof checkElement === 'string') { const escapedAdjacentHTML = replaceTdCellsWithTextContent(checkElement); tempElem.insertAdjacentHTML('afterbegin', `${escapedAdjacentHTML}`); checkElement = tempElem.querySelector('table'); } if (!checkElement || !isHTMLTable(checkElement)) { return; } const generator = tempElem.querySelector('meta[name$="enerator"]'); const hasRowHeaders = checkElement.querySelector('tbody th') !== null; const trElement = checkElement.querySelector('tr'); const countCols = !trElement ? 0 : Array.from(trElement.cells).reduce((cols, cell) => cols + cell.colSpan, 0) - (hasRowHeaders ? 1 : 0); const fixedRowsBottom = checkElement.tFoot && Array.from(checkElement.tFoot.rows) || []; const fixedRowsTop = []; let hasColHeaders = false; let thRowsLen = 0; let countRows = 0; if (checkElement.tHead) { const thRows = Array.from(checkElement.tHead.rows).filter(tr => { const isDataRow = tr.querySelector('td') !== null; if (isDataRow) { fixedRowsTop.push(tr); } return !isDataRow; }); thRowsLen = thRows.length; hasColHeaders = thRowsLen > 0; if (thRowsLen > 1) { settingsObj.nestedHeaders = Array.from(thRows).reduce((rows, row) => { const headersRow = Array.from(row.cells).reduce((headers, header, currentIndex) => { if (hasRowHeaders && currentIndex === 0) { return headers; } const { colSpan: colspan, innerHTML } = header; const nextHeader = colspan > 1 ? { label: innerHTML, colspan } : innerHTML; headers.push(nextHeader); return headers; }, []); rows.push(headersRow); return rows; }, []); } else if (hasColHeaders) { settingsObj.colHeaders = Array.from(thRows[0].children).reduce((headers, header, index) => { if (hasRowHeaders && index === 0) { return headers; } headers.push(header.innerHTML); return headers; }, []); } } if (fixedRowsTop.length) { settingsObj.fixedRowsTop = fixedRowsTop.length; } if (fixedRowsBottom.length) { settingsObj.fixedRowsBottom = fixedRowsBottom.length; } const dataRows = [...fixedRowsTop, ...Array.from(checkElement.tBodies).reduce((sections, section) => { sections.push(...Array.from(section.rows)); return sections; }, []), ...fixedRowsBottom]; countRows = dataRows.length; const dataArr = new Array(countRows); for (let r = 0; r < countRows; r++) { dataArr[r] = new Array(countCols); } const mergeCells = []; const rowHeaders = []; for (let row = 0; row < countRows; row++) { const tr = dataRows[row]; const cells = Array.from(tr.cells); const cellsLen = cells.length; for (let cellId = 0; cellId < cellsLen; cellId++) { const cell = cells[cellId]; const { nodeName, innerHTML, rowSpan: rowspan, colSpan: colspan } = cell; const col = dataArr[row].findIndex(value => value === undefined); if (nodeName === 'TD') { if (rowspan > 1 || colspan > 1) { for (let rstart = row; rstart < row + rowspan; rstart++) { if (rstart < countRows) { for (let cstart = col; cstart < col + colspan; cstart++) { dataArr[rstart][cstart] = null; } } } const styleAttr = cell.getAttribute('style'); const ignoreMerge = styleAttr && styleAttr.includes('mso-ignore:colspan'); if (!ignoreMerge) { mergeCells.push({ col, row, rowspan, colspan }); } } let cellValue = ''; if (generator && /excel/gi.test(generator.content)) { cellValue = innerHTML.replace(/[\r\n][\x20]{0,2}/g, '\x20').replace(/<br(\s*|\/)>[\r\n]?[\x20]{0,3}/gim, '\r\n'); } else { cellValue = innerHTML.replace(/<br(\s*|\/)>[\r\n]?/gim, '\r\n'); } dataArr[row][col] = cellValue.replace(regEscapedChars, match => ESCAPED_HTML_CHARS[match]); } else { rowHeaders.push(innerHTML); } } } if (mergeCells.length) { settingsObj.mergeCells = mergeCells; } if (rowHeaders.length) { settingsObj.rowHeaders = rowHeaders; } if (dataArr.length) { settingsObj.data = dataArr; } return settingsObj; }