UNPKG

@atlaskit/editor-plugin-paste

Version:

Paste plugin for @atlaskit/editor-core

288 lines (274 loc) 10.8 kB
import { isListNode, mapChildren, mapSlice } from '@atlaskit/editor-common/utils'; import { autoJoin } from '@atlaskit/editor-prosemirror/commands'; import { Fragment, Slice } from '@atlaskit/editor-prosemirror/model'; import { EditorState } from '@atlaskit/editor-prosemirror/state'; import { PastePluginActionTypes as ActionTypes } from '../editor-actions/actions'; import { createCommand } from '../pm-plugins/plugin-factory'; /** * Use this to register macro link positions during a paste operation, that you * want to track in a document over time, through any document changes. * * @param positions a map of string keys (custom position references) and position values e.g. { ['my-key-1']: 11 } * * **Context**: This is neccessary if there is an async process or an unknown period of time * between obtaining an original position, and wanting to know about what its final eventual * value. In that scenario, positions will need to be actively tracked and mapped in plugin * state so that they can be mapped through any other independent document change transactions being * dispatched to the editor that could affect their value. */ export const startTrackingPastedMacroPositions = pastedMacroPositions => createCommand(() => { return { type: ActionTypes.START_TRACKING_PASTED_MACRO_POSITIONS, pastedMacroPositions }; }); export const stopTrackingPastedMacroPositions = pastedMacroPositionKeys => createCommand(() => { return { type: ActionTypes.STOP_TRACKING_PASTED_MACRO_POSITIONS, pastedMacroPositionKeys }; }); // matchers for text lists // Ignored via go/ees005 // eslint-disable-next-line require-unicode-regexp const bullets = /^\s*[\*\-\u2022](\s+|\s+$)/; // Ignored via go/ees005 // eslint-disable-next-line require-unicode-regexp const numbers = /^\s*\d[\.\)](\s+|$)/; const isListItem = (node, schema) => { return Boolean(node && node.type === schema.nodes.listItem); }; const getListType = (node, schema) => { if (!node || !node.text) { return null; } const { bulletList, orderedList } = schema.nodes; return [{ node: bulletList, matcher: bullets }, { node: orderedList, matcher: numbers }].reduce((lastMatch, listType) => { if (lastMatch) { return lastMatch; } // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const match = node.text.match(listType.matcher); return match ? [listType.node, match[0].length] : lastMatch; }, null); }; // Splits array of nodes by hardBreak. E.g.: // [text "1. ", em "hello", date, hardbreak, text "2. ", // subsup "world", hardbreak, text "smile"] // => [ // [ text "1. ", em "hello", date ], // [hardbreak, text "2. ", subsup "world"], // [hardbreak, text "smile"] // ] export const _contentSplitByHardBreaks = (content, schema) => { const wrapperContent = []; let nextContent = []; content.forEach(node => { if (node.type === schema.nodes.hardBreak) { if (nextContent.length !== 0) { wrapperContent.push(nextContent); } nextContent = [node]; } else { nextContent.push(node); } }); wrapperContent.push(nextContent); return wrapperContent; }; export const extractListFromParagraph = (node, parent, schema) => { const content = mapChildren(node.content, node => node); const linesSplitByHardbreaks = _contentSplitByHardBreaks(content, schema); const { paragraph, hardBreak, listItem, orderedList } = schema.nodes; const splitListsAndParagraphs = []; let paragraphParts = []; // Ignored via go/ees005 // eslint-disable-next-line no-var for (var index = 0; index < linesSplitByHardbreaks.length; index = index + 1) { var _firstNonHardBreakNod; const line = linesSplitByHardbreaks[index]; let listMatch; if (index === 0) { var _line$; if (((_line$ = line[0]) === null || _line$ === void 0 ? void 0 : _line$.type) === hardBreak) { paragraphParts.push(line); continue; } else { // the first line the potential list item is at postion 0 listMatch = getListType(line[0], schema); } } else { // lines after the first the potential list item is at postion 1 if (line.length === 1) { // if the line only has a line break return as is paragraphParts.push(line); continue; } listMatch = getListType(line[1], schema); } if (!listMatch || // CONFCLOUD-79708: If we are inside a list - let's not try to upgrade list as it resolves // to invalid content isListItem(parent, schema)) { // if there is not list match return as is paragraphParts.push(line); continue; } const [nodeType, length] = listMatch; const firstNonHardBreakNode = line.find(node => node.type !== hardBreak); const chunksWithoutLeadingHardBreaks = line.slice(line.findIndex(node => node.type !== hardBreak)); // retain text after bullet or number-dot e.g. 1. Hello const startingText = firstNonHardBreakNode === null || firstNonHardBreakNode === void 0 ? void 0 : (_firstNonHardBreakNod = firstNonHardBreakNode.text) === null || _firstNonHardBreakNod === void 0 ? void 0 : _firstNonHardBreakNod.substr(length); const restOfChunk = startingText ? // apply transformation to first entry [schema.text(startingText, firstNonHardBreakNode === null || firstNonHardBreakNode === void 0 ? void 0 : firstNonHardBreakNode.marks), ...chunksWithoutLeadingHardBreaks.slice(1)] : chunksWithoutLeadingHardBreaks.slice(1); // convert to list const listItemNode = listItem === null || listItem === void 0 ? void 0 : listItem.createAndFill(undefined, paragraph.createChecked(undefined, restOfChunk)); if (!listItemNode) { paragraphParts.push(line); continue; } const attrs = nodeType === orderedList ? { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @atlassian/perf-linting/no-expensive-split-replace -- Ignored via go/ees017 (to be fixed) order: parseInt(firstNonHardBreakNode.text.split('.')[0]) } : undefined; const newList = nodeType.createChecked(attrs, [listItemNode]); if (paragraphParts.length !== 0 && paragraph.validContent(Fragment.from(paragraphParts.flat()))) { splitListsAndParagraphs.push(paragraph.createAndFill(node.attrs, paragraphParts.flat(), node.marks)); paragraphParts = []; } splitListsAndParagraphs.push(newList); } if (paragraphParts.length !== 0 && paragraph.validContent(Fragment.from(paragraphParts.flat()))) { splitListsAndParagraphs.push(schema.nodes.paragraph.createAndFill(node.attrs, paragraphParts.flat(), node.marks)); } const result = splitListsAndParagraphs.flat(); // try to join const mockState = EditorState.create({ schema }); let joinedListsTr; const mockDispatch = tr => { joinedListsTr = tr; }; // Return false to prevent replaceWith from wrapping the text node in a paragraph // paragraph since that will be done later. If it's done here, it will fail // the paragraph.validContent check. // Dont return false if there are lists, as they arent validContent for paragraphs // and will result in hanging textNodes autoJoin((state, dispatch) => { if (!dispatch) { return false; } const containsList = result.some(node => node.type === schema.nodes.bulletList || node.type === schema.nodes.orderedList); if (result.some(node => node.isText) && !containsList) { return false; } dispatch(state.tr.replaceWith(0, 2, result)); return true; }, (before, after) => isListNode(before) && isListNode(after))(mockState, mockDispatch); const fragment = joinedListsTr ? joinedListsTr.doc.content : Fragment.from(result); return fragment; }; // above will wrap everything in paragraphs for us export const upgradeTextToLists = (slice, schema) => { return mapSlice(slice, (node, parent) => { if (node.type === schema.nodes.paragraph) { return extractListFromParagraph(node, parent, schema); } return node; }); }; export const splitParagraphs = (slice, schema) => { // exclude Text nodes with a code mark, since we transform those later // into a codeblock let hasCodeMark = false; slice.content.forEach(child => { hasCodeMark = hasCodeMark || child.marks.some(mark => mark.type === schema.marks.code); }); // slice might just be a raw text string if (schema.nodes.paragraph.validContent(slice.content) && !hasCodeMark) { const replSlice = splitIntoParagraphs({ fragment: slice.content, schema }); return new Slice(replSlice, slice.openStart + 1, slice.openEnd + 1); } return mapSlice(slice, node => { if (node.type === schema.nodes.paragraph) { return splitIntoParagraphs({ fragment: node.content, blockMarks: node.marks, schema }); } return node; }); }; /** * Walks the slice, creating paragraphs that were previously separated by hardbreaks. * Returns the original paragraph node (as a fragment), or a fragment containing multiple nodes. */ export const splitIntoParagraphs = ({ fragment, blockMarks = [], schema }) => { const paragraphs = []; let curChildren = []; let lastNode = null; const { hardBreak, paragraph } = schema.nodes; fragment.forEach((node, i) => { const isNodeValidContentForParagraph = schema.nodes.paragraph.validContent(Fragment.from(node)); if (!isNodeValidContentForParagraph) { paragraphs.push(node); return; } // ED-14725 Fixed the issue that it make duplicated line // when pasting <br /> from google docs. if (i === 0 && node.type === hardBreak) { paragraphs.push(paragraph.createChecked(undefined, curChildren, [...blockMarks])); lastNode = node; return; } else if (lastNode && lastNode.type === hardBreak && node.type === hardBreak) { // double hardbreak // backtrack a little; remove the trailing hardbreak we added last loop curChildren.pop(); // create a new paragraph paragraphs.push(paragraph.createChecked(undefined, curChildren, [...blockMarks])); curChildren = []; return; } // add to this paragraph curChildren.push(node); lastNode = node; }); if (curChildren.length) { paragraphs.push(paragraph.createChecked(undefined, curChildren, [...blockMarks])); } return Fragment.from(paragraphs.length ? paragraphs : // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion [paragraph.createAndFill(undefined, undefined, [...blockMarks])]); };