UNPKG

@atlaskit/editor-common

Version:

A package that contains common classes and components for editor and renderer

208 lines (204 loc) 6.49 kB
import { TextSelection } from '@atlaskit/editor-prosemirror/state'; // eslint-disable-next-line no-duplicate-imports import { CellSelection } from '@atlaskit/editor-tables/cell-selection'; import { getBooleanFF } from '@atlaskit/platform-feature-flags'; const SMART_TO_ASCII = { '…': '...', '→': '->', '←': '<-', '–': '--', '“': '"', '”': '"', '‘': "'", '’': "'" }; const FIND_SMART_CHAR = new RegExp(`[${Object.keys(SMART_TO_ASCII).join('')}]`, 'g'); const isNodeTextBlock = schema => { const { mention, text, emoji } = schema.nodes; return (node, _, parent) => { if (node.type === mention || node.type === emoji || node.type === text) { return parent === null || parent === void 0 ? void 0 : parent.isTextblock; } return; }; }; const replaceSmartCharsToAscii = (position, textContent, tr) => { const { schema } = tr.doc.type; let match; while (match = FIND_SMART_CHAR.exec(textContent)) { const { 0: smartChar, index: offset } = match; const replacePos = tr.mapping.map(position + offset); const replacementText = schema.text(SMART_TO_ASCII[smartChar]); tr.replaceWith(replacePos, replacePos + smartChar.length, replacementText); } }; const replaceMentionOrEmojiForTextContent = (position, nodeSize, textContent, tr) => { const currentPos = tr.mapping.map(position); const { schema } = tr.doc.type; tr.replaceWith(currentPos, currentPos + nodeSize, schema.text(textContent)); }; export function filterChildrenBetween(doc, from, to, predicate) { const results = []; doc.nodesBetween(from, to, (node, pos, parent) => { if (predicate(node, pos, parent)) { results.push({ node, pos }); } }); return results; } export const transformSmartCharsMentionsAndEmojis = (from, to, tr) => { const { schema } = tr.doc.type; const { mention, text, emoji } = schema.nodes; // Traverse through all the nodes within the range and replace them with their plaintext counterpart const children = filterChildrenBetween(tr.doc, from, to, isNodeTextBlock(schema)); children.forEach(({ node, pos }) => { if (node.type === mention || node.type === emoji) { // Convert gracefully when no text found, ProseMirror will blow up if you try to create a node with an empty string or undefined let replacementText = node.attrs.text; if (typeof replacementText === 'undefined' || replacementText === '') { replacementText = `${node.type.name} text missing`; } replaceMentionOrEmojiForTextContent(pos, node.nodeSize, replacementText, tr); } else if (node.type === text && node.text) { const replacePosition = pos > from ? pos : from; const textToReplace = pos > from ? node.text : node.text.substr(from - pos); replaceSmartCharsToAscii(replacePosition, textToReplace, tr); } }); }; export const applyMarkOnRange = (from, to, removeMark, mark, tr) => { const { schema } = tr.doc.type; const { code } = schema.marks; const { inlineCard } = schema.nodes; if (mark.type === code) { transformSmartCharsMentionsAndEmojis(from, to, tr); } tr.doc.nodesBetween(tr.mapping.map(from), tr.mapping.map(to), (node, pos) => { if (getBooleanFF('platform.editor.allow-inline-comments-for-inline-nodes')) { if (!node.isText && node.type !== inlineCard) { return true; } } else { if (!node.isText) { return true; } } // This is an issue when the user selects some text. // We need to check if the current node position is less than the range selection from. // If it’s true, that means we should apply the mark using the range selection, // not the current node position. const nodeBetweenFrom = Math.max(pos, tr.mapping.map(from)); const nodeBetweenTo = Math.min(pos + node.nodeSize, tr.mapping.map(to)); if (removeMark) { tr.removeMark(nodeBetweenFrom, nodeBetweenTo, mark); } else { tr.addMark(nodeBetweenFrom, nodeBetweenTo, mark); } return true; }); return tr; }; const entireSelectionContainsMark = (mark, doc, fromPos, toPos) => { let onlyContainsMark = true; doc.nodesBetween(fromPos, toPos, node => { // Skip recursion once we've found text which doesn't include the mark if (!onlyContainsMark) { return false; } if (node.isText) { onlyContainsMark && (onlyContainsMark = mark.isInSet(node.marks)); } }); return onlyContainsMark; }; const toggleMarkInRange = mark => ({ tr }) => { if (tr.selection instanceof CellSelection) { let removeMark = true; const cells = []; tr.selection.forEachCell((cell, cellPos) => { cells.push({ node: cell, pos: cellPos }); const from = cellPos; const to = cellPos + cell.nodeSize; removeMark && (removeMark = entireSelectionContainsMark(mark, tr.doc, from, to)); }); for (let i = cells.length - 1; i >= 0; i--) { const cell = cells[i]; const from = cell.pos; const to = from + cell.node.nodeSize; applyMarkOnRange(from, to, removeMark, mark, tr); } } else { const { $from, $to } = tr.selection; // We decide to remove the mark only if the entire selection contains the mark // Examples with *bold* text // Scenario 1: Selection contains both bold and non-bold text -> bold entire selection // Scenario 2: Selection contains only bold text -> un-bold entire selection // Scenario 3: Selection contains no bold text -> bold entire selection const removeMark = entireSelectionContainsMark(mark, tr.doc, $from.pos, $to.pos); applyMarkOnRange($from.pos, $to.pos, removeMark, mark, tr); } if (tr.docChanged) { return tr; } return null; }; /** * A custom version of the ProseMirror toggleMark, where we only toggle marks * on text nodes in the selection rather than all inline nodes. * @param markType * @param attrs */ export const toggleMark = (markType, attrs) => ({ tr }) => { const mark = markType.create(attrs); // For cursor selections we can use the default behaviour. if (tr.selection instanceof TextSelection && tr.selection.$cursor) { if (mark.isInSet(tr.storedMarks || tr.selection.$cursor.marks())) { tr.removeStoredMark(mark); } else { tr.addStoredMark(mark); } return tr; } return toggleMarkInRange(mark)({ tr }); };