@atlaskit/editor-plugin-paste
Version:
Paste plugin for @atlaskit/editor-core
288 lines (274 loc) • 10.8 kB
JavaScript
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])]);
};