@atlaskit/editor-plugin-paste
Version:
Paste plugin for @atlaskit/editor-core
173 lines (169 loc) • 7.64 kB
JavaScript
import chunk from 'lodash/chunk';
const isPastedFromTinyMCE = pasteEvent => {
var _pasteEvent$clipboard, _pasteEvent$clipboard2, _pasteEvent$clipboard3;
return (_pasteEvent$clipboard = pasteEvent === null || pasteEvent === void 0 ? void 0 : (_pasteEvent$clipboard2 = pasteEvent.clipboardData) === null || _pasteEvent$clipboard2 === void 0 ? void 0 : (_pasteEvent$clipboard3 = _pasteEvent$clipboard2.types) === null || _pasteEvent$clipboard3 === void 0 ? void 0 : _pasteEvent$clipboard3.some(mimeType => mimeType === 'x-tinymce/html')) !== null && _pasteEvent$clipboard !== void 0 ? _pasteEvent$clipboard : false;
};
export const isPastedFromTinyMCEConfluence = (pasteEvent, html) => {
return isPastedFromTinyMCE(pasteEvent) && !!html &&
// Ignored via go/ees005
// eslint-disable-next-line require-unicode-regexp
!!html.match(/class=\"\s?(confluenceTd|confluenceTh|confluenceTable).+"/gim);
};
/**
* Wraps html markup with a `<table>` and uses `DOMParser` to generate
* and return both the table-wrapped and non-table-wrapped DOM document
* instances.
*/
export const wrapWithTable = html => {
const parser = new DOMParser();
const nonTableWrappedDoc = parser.parseFromString(html, 'text/html');
const tableWrappedDoc = parser.parseFromString(`<table>${html}</table>`, 'text/html');
tableWrappedDoc.body.querySelectorAll('meta').forEach(meta => {
tableWrappedDoc.head.prepend(meta);
});
return {
tableWrappedDoc,
nonTableWrappedDoc
};
};
const exactlyDivisible = (larger, smaller) => larger % smaller === 0;
const getTableElementsInfo = doc => {
const cellCount = doc.querySelectorAll('td').length;
const thCount = doc.querySelectorAll('th').length;
const mergedCellCount = doc.querySelectorAll('td[colspan]:not([colspan="1"])').length;
let hasThAfterTd = false;
const thsAndCells = doc.querySelectorAll('th,td');
for (let i = 0, cellFound = false; i < thsAndCells.length; i++) {
if (cellFound && thsAndCells[i].nodeName === 'TH') {
hasThAfterTd = true;
break;
}
if (thsAndCells[i].nodeName === 'TD') {
cellFound = true;
}
}
const onlyTh = thCount > 0 && cellCount === 0;
const onlyCells = cellCount > 0 && thCount === 0;
const hasCompleteRow =
// we take header-only and cell-only tables to be
// row-complete
onlyTh || onlyCells ||
// if headers and cells can "fit" against each other,
// then we assume a complete row exists
(exactlyDivisible(thCount, cellCount) || exactlyDivisible(cellCount, thCount)) &&
// all numbers are divisible by 1, so we carve out a specific
// check for when there is only 1 table cell, and more than 1
// table header.
!(thCount > 1 && cellCount === 1);
return {
cellCount,
thCount,
mergedCellCount,
hasThAfterTd,
hasIncompleteRow: !hasCompleteRow
};
};
const configureTableRows = (doc, colsInRow) => {
var _Array$from;
const tableHeadersAndCells = Array.from(doc.body.querySelectorAll('th,td'));
const evenlySplitChunks = chunk(tableHeadersAndCells, colsInRow);
const tableBody = doc.body.querySelector('tbody');
evenlySplitChunks.forEach(chunk => {
const tr = doc.createElement('tr');
tableBody === null || tableBody === void 0 ? void 0 : tableBody.append(tr);
tr.append(...chunk);
});
// We remove any leftover empty rows which may cause fabric editor
// to no-op when parsing the table
const emptyRows = (_Array$from = Array.from(tableBody.querySelectorAll('tr'))) === null || _Array$from === void 0 ? void 0 : _Array$from.filter(row => row.innerHTML.trim().length === 0);
emptyRows.forEach(row => row.remove());
return doc.body.innerHTML;
};
const fillIncompleteRowWithEmptyCells = (doc, thCount, cellCount) => {
var _lastCell$parentEleme;
let extraCellsCount = 0;
while (!exactlyDivisible(cellCount + extraCellsCount, thCount)) {
extraCellsCount++;
}
const extraEmptyCells = Array.from(Array(extraCellsCount)).map(() => doc.createElement('td'));
const lastCell = doc.body.querySelector('td:last-of-type');
lastCell === null || lastCell === void 0 ? void 0 : (_lastCell$parentEleme = lastCell.parentElement) === null || _lastCell$parentEleme === void 0 ? void 0 : _lastCell$parentEleme.append(...extraEmptyCells);
return {
updatedCellCount: cellCount + extraCellsCount
};
};
/**
* Given a DOM document, it will try to rebuild table rows by using the
* table headers count as an initial starting point for the assumed
* number of columns that make up a row (`colsInRow`). It will slowly
* decrease that `colsInRow` count until it finds exact fit for table
* headers and cells with `colsInRow` else it returns the original
* document's markup.
*
* NOTE: It will NOT try to rebuild table rows if it encounters merged cells
* or compex table configurations (where table headers exist after normal
* table cells). It will build a single column table if NO table
* headers exist.
*/
export const tryReconstructTableRows = doc => {
// Ignored via go/ees005
// eslint-disable-next-line prefer-const
let {
cellCount,
thCount,
mergedCellCount,
hasThAfterTd,
hasIncompleteRow
} = getTableElementsInfo(doc);
if (mergedCellCount || hasThAfterTd) {
// bail out to avoid handling more complex table structures
return doc.body.innerHTML;
}
if (!thCount) {
// if no table headers exist for reference, fallback to a single column table structure
return configureTableRows(doc, 1);
}
if (hasIncompleteRow) {
// if shift-click selection copies a partial table row to the clipboard,
// and we do have table headers for reference, then we add empty table cells
// to fill out the partial row
const {
updatedCellCount
} = fillIncompleteRowWithEmptyCells(doc, thCount, cellCount);
cellCount = updatedCellCount;
}
for (let possibleColsInRow = thCount; possibleColsInRow > 0; possibleColsInRow--) {
if (exactlyDivisible(thCount, possibleColsInRow) && exactlyDivisible(cellCount, possibleColsInRow)) {
return configureTableRows(doc, possibleColsInRow);
}
}
return doc.body.innerHTML;
};
export const htmlHasIncompleteTable = html => {
return !html.includes('<table ') && (html.includes('<td ') || html.includes('<th '));
};
/**
* Strictly for ED-7331. Given incomplete table html from tinyMCE, it will try to rebuild
* a whole valid table. If it rebuilds the table, it may first rebuild it as a single
* row table, so this also then tries to reconstruct the table rows/columns if
* possible (best effort).
*/
export const tryRebuildCompleteTableHtml = incompleteTableHtml => {
// first we try wrapping the table elements with <table> and let DOMParser try to rebuild
// a valid DOM tree. we also keep the non-wrapped table for comparison purposes.
const {
nonTableWrappedDoc,
tableWrappedDoc
} = wrapWithTable(incompleteTableHtml);
const didPreserveTableElements = Boolean(!nonTableWrappedDoc.body.querySelector('th, td') && tableWrappedDoc.body.querySelector('th, td'));
const isExpectedStructure = tableWrappedDoc.querySelectorAll('body > table:only-child') && !tableWrappedDoc.querySelector(`body > table > tbody > tr > :not(th,td)`);
// if DOMParser saves table elements that we would otherwise lose, and
// if the table html is what we'd expect (a single table, with no extraneous
// elements in table rows other than th, td), then we can now also try to
// rebuild table rows/columns.
if (didPreserveTableElements && isExpectedStructure) {
const completeTableHtml = tryReconstructTableRows(tableWrappedDoc);
return completeTableHtml;
}
return null;
};