@atlaskit/editor-plugin-paste
Version:
Paste plugin for @atlaskit/editor-core
1,123 lines (1,073 loc) • 63.3 kB
JavaScript
// eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
import uuid from 'uuid/v4';
import { INPUT_METHOD } from '@atlaskit/editor-common/analytics';
import { addLinkMetadata } from '@atlaskit/editor-common/card';
import { insideTable } from '@atlaskit/editor-common/core-utils';
import { getBlockMarkAttrs, getFirstParagraphBlockMarkAttrs, reconcileBlockMarkForContainerAtPos, reconcileBlockMarkForParagraphAtPos, reconcileBlockMarkInRange } from '@atlaskit/editor-common/lists';
import { anyMarkActive } from '@atlaskit/editor-common/mark';
import { getParentOfTypeCount, getPositionAfterTopParentNodeOfType } from '@atlaskit/editor-common/nesting';
import { GapCursorSelection, Side } from '@atlaskit/editor-common/selection';
import { canLinkBeCreatedInRange, insideTableCell, isInListItem, isLinkMark, isListItemNode, isListNode, isNodeEmpty, isParagraph, isText, linkifyContent, mapSlice } from '@atlaskit/editor-common/utils';
import { Fragment, Node as PMNode, Slice } from '@atlaskit/editor-prosemirror/model';
import { AllSelection, NodeSelection, TextSelection } from '@atlaskit/editor-prosemirror/state';
import { canInsert, contains, findParentNodeOfType, findParentNodeOfTypeClosestToPos, hasParentNode, hasParentNodeOfType, safeInsert } from '@atlaskit/editor-prosemirror/utils';
import { replaceSelectedTable } from '@atlaskit/editor-tables/utils';
import { fg } from '@atlaskit/platform-feature-flags';
import { closeHistory } from '@atlaskit/prosemirror-history';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
// TODO: ED-20519 - Needs Macro extraction
import { startTrackingPastedMacroPositions, stopTrackingPastedMacroPositions } from '../../editor-commands/commands';
import { getPluginState as getPastePluginState } from '../plugin-factory';
import { insertSliceForLists, insertSliceForTaskInsideList, insertSliceInsideBlockquote, updateSelectionAfterReplace } from './edge-cases';
import { insertSliceInsideOfPanelNodeSelected } from './edge-cases/lists';
import { addReplaceSelectedTableAnalytics, applyTextMarksToSlice, hasOnlyNodesOfType, isEmptyNode, isSelectionInsidePanel } from './index';
const insideExpand = state => {
const {
expand,
nestedExpand
} = state.schema.nodes;
return hasParentNodeOfType([expand, nestedExpand])(state.selection);
};
/** Helper type for single arg function */
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Compose 1 to n functions.
* @param func first function
* @param funcs additional functions
*/
function compose(func, ...funcs) {
const allFuncs = [func, ...funcs];
return function composed(raw) {
return allFuncs.reduceRight((memo, func) => func(memo), raw);
};
}
/* eslint-enable @typescript-eslint/no-explicit-any */
// remove text attribute from mention for copy/paste (GDPR)
export function handleMention(slice, schema) {
return mapSlice(slice, node => {
var _schema$nodes$mention;
// We should move this to the mention plugin when we refactor how paste works in the future
// For now we can just null check mention exists in the schema to ensure we don't crash if it doesn't
// exist.
if (node.type.name === ((_schema$nodes$mention = schema.nodes.mention) === null || _schema$nodes$mention === void 0 ? void 0 : _schema$nodes$mention.name)) {
const mention = node.attrs;
const newMention = {
...mention,
text: ''
};
return schema.nodes.mention.create(newMention, node.content, node.marks);
}
return node;
});
}
export function handlePasteIntoTaskOrDecisionOrPanel(slice, queueCardsFromChangedTr) {
return (state, dispatch) => {
var _slice$content$firstC, _slice$content$firstC2, _slice$content$firstC3, _transformedSlice$con;
const {
schema,
tr: {
selection
}
} = state;
const {
marks: {
code: codeMark
},
nodes: {
decisionItem,
emoji,
hardBreak,
mention,
paragraph,
taskItem,
text,
panel,
bulletList,
orderedList,
taskList,
listItem,
expand,
heading,
codeBlock
}
} = schema;
const selectionIsValidNode = state.selection instanceof NodeSelection && ['decisionList', 'decisionItem', 'taskList', 'taskItem'].includes(state.selection.node.type.name);
const selectionHasValidParentNode = hasParentNodeOfType([decisionItem, taskItem, panel])(state.selection);
const selectionIsCodeBlock = hasParentNodeOfType([codeBlock])(state.selection);
const selectionIsListItem = hasParentNodeOfType([listItem])(state.selection);
const panelNode = isSelectionInsidePanel(selection);
const selectionIsPanel = Boolean(panelNode);
const isSliceWholePanel = ((_slice$content$firstC = slice.content.firstChild) === null || _slice$content$firstC === void 0 ? void 0 : _slice$content$firstC.type) === panel && slice.openStart === 0 && slice.openEnd === 0;
// we avoid handling codeBlock-in-panel use case in this function
// returning false will allow code to flow into `handleCodeBlock` function
// Partial content copied from panels will have panel in the slice
// Return false to avoid handling this situation when pasted into list in panel and let `handlePastePanelOrDecisionContentIntoList` handle it
if (selectionIsPanel && (selectionIsCodeBlock || selectionIsListItem && !isSliceWholePanel && expValEquals('platform_editor_pasting_text_in_panel', 'isEnabled', true))) {
return false;
}
// Some types of content should be handled by the default handler, not this function.
// Check through slice content to see if it contains an invalid node.
let sliceIsInvalid = false;
let sliceHasTask = false;
slice.content.nodesBetween(0, slice.content.size, node => {
if (node.type === bulletList || node.type === orderedList || node.type === expand || node.type === heading || node.type === listItem) {
sliceIsInvalid = true;
}
if (selectionIsPanel && node.type === taskList) {
sliceHasTask = true;
}
});
// If the selection is a panel,
// and the slice's first node is a paragraph
// and it is not from a depth that would indicate it being from inside from another node (e.g. text from a decision)
// then we can rely on the default behaviour.
const selectionIsTaskOrDecision = hasParentNode(node => node.type === taskItem || node.type === decisionItem)(selection);
const sliceIsAPanelReceivingLowDepthText = selectionIsPanel && !selectionIsTaskOrDecision && ((_slice$content$firstC2 = slice.content.firstChild) === null || _slice$content$firstC2 === void 0 ? void 0 : _slice$content$firstC2.type) === paragraph && slice.openEnd < 2;
if (sliceIsInvalid || sliceIsAPanelReceivingLowDepthText || !selectionIsValidNode && !selectionHasValidParentNode) {
return false;
}
const filters = [linkifyContent(schema)];
const selectionMarks = selection.$head.marks();
if (selection instanceof TextSelection && Array.isArray(selectionMarks) && selectionMarks.length > 0 && hasOnlyNodesOfType(paragraph, text, emoji, mention, hardBreak)(slice) && (!codeMark.isInSet(selectionMarks) || anyMarkActive(state, codeMark)) // check if there is a code mark anywhere in the selection
) {
filters.push(applyTextMarksToSlice(schema, selection.$head.marks()));
}
const transformedSlice = compose.apply(null, filters)(slice);
const isFirstChildTaskNode = transformedSlice.content.firstChild.type === taskList || transformedSlice.content.firstChild.type === taskItem;
const tr = closeHistory(state.tr);
if (panelNode && sliceHasTask && ((_slice$content$firstC3 = slice.content.firstChild) === null || _slice$content$firstC3 === void 0 ? void 0 : _slice$content$firstC3.type) === panel && isEmptyNode(panelNode) && selection.$from.node() === selection.$to.node()) {
return Boolean(insertSliceInsideOfPanelNodeSelected(panelNode)({
tr,
slice
}));
}
const transformedSliceIsValidNode = (transformedSlice.content.firstChild.type.inlineContent || ['decisionList', 'decisionItem', 'taskItem', 'taskList', 'panel'].includes(transformedSlice.content.firstChild.type.name)) && (!isInListItem(state) || isInListItem(state) && isFirstChildTaskNode);
// If the slice or the selection are valid nodes to handle,
// and the slice is not a whole node (i.e. openStart is 1 and openEnd is 0)
// or the slice's first node is a paragraph,
// then we can replace the selection with our slice.
const pastingIntoExtendedPanel = selectionIsPanel && panel.validContent(transformedSlice.content);
if ((transformedSliceIsValidNode || selectionIsValidNode) && !pastingIntoExtendedPanel && !(transformedSlice.openStart === 1 && transformedSlice.openEnd === 0 ||
// Whole codeblock node has reverse slice depths.
transformedSlice.openStart === 0 && transformedSlice.openEnd === 1) || ((_transformedSlice$con = transformedSlice.content.firstChild) === null || _transformedSlice$con === void 0 ? void 0 : _transformedSlice$con.type) === paragraph) {
tr.replaceSelection(transformedSlice).scrollIntoView();
} else {
const isWholeContentSelected = selection.$from.pos === selection.$from.start() && selection.$to.end() === selection.$to.pos;
if (pastingIntoExtendedPanel && selection.$from.pos !== selection.$to.pos && !isWholeContentSelected) {
// Do a replaceSelection if the entire panel content isn't selected
//tr.replaceSelection(transformedSlice).scrollIntoView();
tr.replaceSelection(new Slice(transformedSlice.content, 0, transformedSlice.openEnd)).scrollIntoView();
} else if (['mediaSingle'].includes(transformedSlice.content.firstChild.type.name) && selectionIsPanel) {
const parentNode = findParentNodeOfType(panel)(selection);
if (selectionIsPanel && parentNode && isNodeEmpty(parentNode.node)) {
tr.insert(selection.$from.pos, transformedSlice.content).scrollIntoView();
// Place the cursor at the the end of the insersertion
const endPos = tr.selection.from + transformedSlice.size;
tr.setSelection(new TextSelection(tr.doc.resolve(endPos)));
} else {
tr.replaceSelection(transformedSlice).scrollIntoView();
}
} else {
var _transformedSlice$con2, _transformedSlice$con3;
if (pastingIntoExtendedPanel && isWholeContentSelected) {
// if the entire panel content is selected, doing a replaceSelection removes the panel as well. Hence we do delete followed by safeInsert
tr.delete(selection.$from.pos, selection.$to.pos);
}
// This maintains both the selection (destination) and the slice (paste content).
safeInsert(transformedSlice.content)(tr).scrollIntoView();
if (((_transformedSlice$con2 = transformedSlice.content.lastChild) === null || _transformedSlice$con2 === void 0 ? void 0 : (_transformedSlice$con3 = _transformedSlice$con2.type) === null || _transformedSlice$con3 === void 0 ? void 0 : _transformedSlice$con3.name) === 'rule') {
tr.setSelection(TextSelection.near(tr.doc.resolve(tr.selection.$from.pos + transformedSlice.content.size)));
} else {
// safeInsert doesn't set correct cursor position
// it moves the cursor to beginning of the node
// we manually shift the cursor to end of the node
const nextPos = tr.doc.resolve(tr.selection.$from.end());
tr.setSelection(new TextSelection(nextPos));
}
}
}
queueCardsFromChangedTr === null || queueCardsFromChangedTr === void 0 ? void 0 : queueCardsFromChangedTr(state, tr, INPUT_METHOD.CLIPBOARD);
if (dispatch) {
dispatch(tr);
}
return true;
};
}
export function handlePasteNonNestableBlockNodesIntoList(slice) {
return (state, dispatch) => {
var _tr$doc$nodeAt, _slice$content$firstC4, _sliceContent$firstCh, _findParentNodeOfType;
const {
tr
} = state;
const {
selection
} = tr;
const {
$from,
$to,
from,
to
} = selection;
const {
orderedList,
bulletList,
listItem
} = state.schema.nodes;
// Selected nodes
const selectionParentListItemNode = findParentNodeOfType(listItem)(selection);
const selectionParentListNodeWithPos = findParentNodeOfType([bulletList, orderedList])(selection);
const selectionParentListNode = selectionParentListNodeWithPos === null || selectionParentListNodeWithPos === void 0 ? void 0 : selectionParentListNodeWithPos.node;
// Slice info
const sliceContent = slice.content;
const sliceIsListItems = isListNode(sliceContent.firstChild) && isListNode(sliceContent.lastChild);
// Find case of slices that can be inserted into a list item
// (eg. paragraphs, list items, code blocks, media single)
// These scenarios already get handled elsewhere and don't need to split the list
let sliceContainsBlockNodesOtherThanThoseAllowedInListItem = false;
slice.content.forEach(child => {
var _listItem$spec$conten;
if (!listItem || child.isBlock && !((_listItem$spec$conten = listItem.spec.content) !== null && _listItem$spec$conten !== void 0 && _listItem$spec$conten.includes(child.type.name))) {
sliceContainsBlockNodesOtherThanThoseAllowedInListItem = true;
}
});
if (!selectionParentListItemNode || !sliceContent || canInsert($from, sliceContent) ||
// eg. inline nodes that can be inserted in a list item
!sliceContainsBlockNodesOtherThanThoseAllowedInListItem || sliceIsListItems || !selectionParentListNodeWithPos) {
return false;
}
// Offsets
const listWrappingOffset = $to.depth - selectionParentListNodeWithPos.depth + 1; // difference in depth between to position and list node
const listItemWrappingOffset = $to.depth - selectionParentListNodeWithPos.depth; // difference in depth between to position and list item node
// Anything to do with nested lists should safeInsert and not be handled here
if (checkIfSelectionInNestedList(state)) {
return false;
}
// Node after the insert position
const nodeAfterInsertPositionIsListItem = ((_tr$doc$nodeAt = tr.doc.nodeAt(to + listItemWrappingOffset)) === null || _tr$doc$nodeAt === void 0 ? void 0 : _tr$doc$nodeAt.type.name) === 'listItem';
// Get the next list items position (used later to find the split out ordered list)
const indexOfNextListItem = $to.indexAfter($to.depth - listItemWrappingOffset);
const positionOfNextListItem = tr.doc.resolve(selectionParentListNodeWithPos.pos + 1).posAtIndex(indexOfNextListItem);
// These nodes paste as plain text by default so need to be handled differently
const sliceContainsNodeThatPastesAsPlainText = sliceContent.firstChild && ['taskItem', 'taskList', 'heading', 'blockquote'].includes(sliceContent.firstChild.type.name);
// Work out position to replace up to
let replaceTo;
if (sliceContainsNodeThatPastesAsPlainText && nodeAfterInsertPositionIsListItem) {
replaceTo = to + listItemWrappingOffset;
} else if (sliceContainsNodeThatPastesAsPlainText || !nodeAfterInsertPositionIsListItem) {
replaceTo = to;
} else {
replaceTo = to + listWrappingOffset;
}
// handle the insertion of the slice
if (((_slice$content$firstC4 = slice.content.firstChild) === null || _slice$content$firstC4 === void 0 ? void 0 : _slice$content$firstC4.type.name) === 'blockquote' && contains(slice.content.firstChild, state.schema.nodes.listItem)) {
insertSliceInsideBlockquote({
tr,
slice
});
} else if (sliceContainsNodeThatPastesAsPlainText || nodeAfterInsertPositionIsListItem || sliceContent.childCount > 1 && ((_sliceContent$firstCh = sliceContent.firstChild) === null || _sliceContent$firstCh === void 0 ? void 0 : _sliceContent$firstCh.type.name) !== 'paragraph') {
tr.replaceWith(from, replaceTo, sliceContent).scrollIntoView();
} else {
// When the selection is not at the end of a list item
// eg. middle of list item, start of list item
tr.replaceSelection(slice).scrollIntoView();
}
// Find the ordered list node after the pasted content so we can set it's order
const mappedPositionOfNextListItem = tr.mapping.map(positionOfNextListItem);
if (mappedPositionOfNextListItem > tr.doc.nodeSize) {
return false;
}
const nodeAfterPastedContentResolvedPos = findParentNodeOfTypeClosestToPos(tr.doc.resolve(mappedPositionOfNextListItem), [orderedList]);
// Work out the new split out lists 'order' (the number it starts from)
const originalParentOrderedListNodeOrder = selectionParentListNode === null || selectionParentListNode === void 0 ? void 0 : selectionParentListNode.attrs.order;
const numOfListItemsInOriginalList = (_findParentNodeOfType = findParentNodeOfTypeClosestToPos(tr.doc.resolve(from - 1), [orderedList])) === null || _findParentNodeOfType === void 0 ? void 0 : _findParentNodeOfType.node.childCount;
// Set the new split out lists order attribute
if (typeof originalParentOrderedListNodeOrder === 'number' && numOfListItemsInOriginalList && nodeAfterPastedContentResolvedPos) {
tr.setNodeMarkup(nodeAfterPastedContentResolvedPos.pos, orderedList, {
...nodeAfterPastedContentResolvedPos.node.attrs,
order: originalParentOrderedListNodeOrder + numOfListItemsInOriginalList
});
}
// dispatch transaction
if (tr.docChanged) {
if (dispatch) {
dispatch(tr);
}
return true;
}
return false;
};
}
export const doesSelectionWhichStartsOrEndsInListContainEntireList = (selection, findRootParentListNode) => {
const {
$from,
$to,
from,
to
} = selection;
const selectionParentListItemNodeResolvedPos = findRootParentListNode ? findRootParentListNode($from) || findRootParentListNode($to) : null;
const selectionParentListNode = selectionParentListItemNodeResolvedPos === null || selectionParentListItemNodeResolvedPos === void 0 ? void 0 : selectionParentListItemNodeResolvedPos.parent;
if (!selectionParentListItemNodeResolvedPos || !selectionParentListNode) {
return false;
}
const startOfEntireList = $from.pos < $to.pos ? selectionParentListItemNodeResolvedPos.pos + $from.depth - 1 : selectionParentListItemNodeResolvedPos.pos + $to.depth - 1;
const endOfEntireList = $from.pos < $to.pos ? selectionParentListItemNodeResolvedPos.pos + selectionParentListNode.nodeSize - $to.depth - 1 : selectionParentListItemNodeResolvedPos.pos + selectionParentListNode.nodeSize - $from.depth - 1;
if (!startOfEntireList || !endOfEntireList) {
return false;
}
if (from < to) {
return startOfEntireList >= $from.pos && endOfEntireList <= $to.pos;
} else if (from > to) {
return startOfEntireList >= $to.pos && endOfEntireList <= $from.pos;
} else {
return false;
}
};
export function handlePastePanelOrDecisionContentIntoList(slice, findRootParentListNode) {
return (state, dispatch) => {
const {
schema,
tr
} = state;
const {
selection
} = tr;
// Check this pasting action is related to copy content from panel node into a selected the list node
const blockNode = slice.content.firstChild;
const isSliceWholeNode = slice.openStart === 0 && slice.openEnd === 0;
const selectionParentListItemNode = selection.$to.node(selection.$to.depth - 1);
const sliceIsWholeNodeButShouldNotReplaceSelection = isSliceWholeNode && !doesSelectionWhichStartsOrEndsInListContainEntireList(selection, findRootParentListNode);
if (!selectionParentListItemNode || (selectionParentListItemNode === null || selectionParentListItemNode === void 0 ? void 0 : selectionParentListItemNode.type) !== schema.nodes.listItem || !blockNode || !['panel', 'decisionList'].includes(blockNode === null || blockNode === void 0 ? void 0 : blockNode.type.name) || slice.content.childCount > 1 || (blockNode === null || blockNode === void 0 ? void 0 : blockNode.content.firstChild) === undefined || sliceIsWholeNodeButShouldNotReplaceSelection) {
return false;
}
// Paste the panel node contents extracted instead of pasting the entire panel node
tr.replaceSelection(slice).scrollIntoView();
if (dispatch) {
dispatch(tr);
}
return true;
};
}
const innerTextRangeOfTextblock = (doc, posOfBlock) => {
const block = doc.nodeAt(posOfBlock);
if (!block || !block.isTextblock) {
return null;
}
// raw content bounds
const contentStart = posOfBlock + 1; // +1 to move from node's start token to content start
const contentEnd = contentStart + block.content.size;
// clamp to doc coord space
const start = Math.max(0, Math.min(contentStart, doc.content.size));
const end = Math.max(0, Math.min(contentEnd, doc.content.size));
if (end <= start) {
return null;
}
// snap to nearest valid text positions
const startSel = TextSelection.findFrom(doc.resolve(start), 1, true);
const endSel = TextSelection.findFrom(doc.resolve(end), -1, true);
if (!startSel || !endSel) {
return null;
}
const from = startSel.$from.pos;
const to = endSel.$to.pos;
return to > from ? {
from,
to
} : null;
};
function resolveSingleTextblockRangeIfAllSelected(state) {
const sel = state.selection;
if (!(sel instanceof AllSelection)) {
return null;
}
let count = 0;
let posOfBlock = -1;
state.doc.nodesBetween(sel.from, sel.to, (node, pos) => {
if (!node.isTextblock) {
return true;
}
count++;
if (count > 1) {
return false;
}
posOfBlock = pos;
return true;
});
if (count !== 1) {
return null;
}
return innerTextRangeOfTextblock(state.doc, posOfBlock);
}
// If we paste a link onto some selected text, apply the link as a mark
export function handlePasteLinkOnSelectedText(slice) {
return (state, dispatch) => {
var _selectAllRange$from, _selectAllRange$to;
const {
schema,
selection,
selection: {
from,
to
},
tr
} = state;
let linkMark;
// check if we have a link on the clipboard
if (slice.content.childCount === 1 && isParagraph(slice.content.child(0), schema)) {
const paragraph = slice.content.child(0);
if (paragraph.content.childCount === 1 && isText(paragraph.content.child(0), schema)) {
const text = paragraph.content.child(0);
// If pasteType is plain text, then
// @atlaskit/editor-markdown-transformer in getMarkdownSlice decode
// url before setting text property of text node.
// However href of marks will be without decoding.
// So, if there is character (e.g space) in url eligible escaping then
// mark.attrs.href will not be equal to text.text.
// That's why decoding mark.attrs.href before comparing.
// However, if pasteType is richText, that means url in text.text
// and href in marks, both won't be decoded.
linkMark = text.marks.find(mark => isLinkMark(mark, schema) && (mark.attrs.href === text.text || decodeURI(mark.attrs.href) === text.text));
}
}
// derive a linkable range if possible for Select‑All over a single textblock
const selectAllRange = fg('platform_editor_link_paste_select_all') ? resolveSingleTextblockRangeIfAllSelected(state) : null;
const rangeFrom = (_selectAllRange$from = selectAllRange === null || selectAllRange === void 0 ? void 0 : selectAllRange.from) !== null && _selectAllRange$from !== void 0 ? _selectAllRange$from : from;
const rangeTo = (_selectAllRange$to = selectAllRange === null || selectAllRange === void 0 ? void 0 : selectAllRange.to) !== null && _selectAllRange$to !== void 0 ? _selectAllRange$to : to;
// if we have a link, apply it to the selected text if we have any and it's allowed
if (linkMark && (selection instanceof TextSelection || Boolean(selectAllRange)) && !selection.empty && canLinkBeCreatedInRange(rangeFrom, rangeTo)(state)) {
tr.addMark(rangeFrom, rangeTo, linkMark);
if (dispatch) {
dispatch(tr);
}
return true;
}
return false;
};
}
export function handlePasteAsPlainText(slice, _event, editorAnalyticsAPI) {
return (state, dispatch, view) => {
var _input;
if (!view) {
return false;
}
// prosemirror-bump-fix
// Yes, this is wrong by default. But, we need to keep the private PAI usage to unblock the prosemirror bump
// So, this code will make sure we are checking for both version (current and the newest prosemirror-view version
const isShiftKeyPressed =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
view.shiftKey || ((_input = view.input) === null || _input === void 0 ? void 0 : _input.shiftKey);
// In case of SHIFT+CMD+V ("Paste and Match Style") we don't want to run the usual
// fuzzy matching of content. ProseMirror already handles this scenario and will
// provide us with slice containing paragraphs with plain text, which we decorate
// with "stored marks".
// @see prosemirror-view/src/clipboard.js:parseFromClipboard()).
// @see prosemirror-view/src/input.js:doPaste().
if (isShiftKeyPressed) {
let tr = closeHistory(state.tr);
const {
selection
} = tr;
// <- using the same internal flag that prosemirror-view is using
// if user has selected table we need custom logic to replace the table
tr = replaceSelectedTable(state, slice);
// add analytics after replacing selected table
tr = addReplaceSelectedTableAnalytics(state, tr, editorAnalyticsAPI);
// otherwise just replace the selection
if (!tr.docChanged) {
tr.replaceSelection(slice);
}
(state.storedMarks || []).forEach(mark => {
tr.addMark(selection.from, selection.from + slice.size, mark);
});
tr.scrollIntoView();
if (dispatch) {
dispatch(tr);
}
return true;
}
return false;
};
}
export function handlePastePreservingMarks(slice, queueCardsFromChangedTr) {
return (state, dispatch) => {
const {
schema,
tr: {
selection
}
} = state;
const {
marks: {
code: codeMark,
annotation: annotationMark
},
nodes: {
bulletList,
emoji,
hardBreak,
heading,
listItem,
mention,
orderedList,
text
}
} = schema;
if (!(selection instanceof TextSelection)) {
return false;
}
const selectionMarks = selection.$head.marks();
if (selectionMarks.length === 0) {
return false;
}
// special case for codeMark: will preserve mark only if codeMark is currently active
// won't preserve mark if cursor is on the edge on the mark (namely inactive)
const hasActiveCodeMark = codeMark && codeMark.isInSet(selectionMarks) && anyMarkActive(state, codeMark);
const hasAnnotationMark = annotationMark && annotationMark.isInSet(selectionMarks);
const selectionIsHeading = hasParentNodeOfType([heading])(state.selection);
// if the pasted data is one of the node types below
// we apply current selection marks to the pasted slice
if (hasOnlyNodesOfType(bulletList, hardBreak, heading, listItem, text, emoji, mention, orderedList)(slice) || selectionIsHeading || hasActiveCodeMark || hasAnnotationMark) {
const transformedSlice = applyTextMarksToSlice(schema, selectionMarks)(slice);
const tr = closeHistory(state.tr).replaceSelection(transformedSlice).setStoredMarks(selectionMarks).scrollIntoView();
queueCardsFromChangedTr === null || queueCardsFromChangedTr === void 0 ? void 0 : queueCardsFromChangedTr(state, tr, INPUT_METHOD.CLIPBOARD);
if (dispatch) {
dispatch(tr);
}
return true;
}
return false;
};
}
async function getSmartLinkAdf(text, type, cardOptions) {
if (!cardOptions.provider) {
throw Error('No card provider found');
}
const provider = await cardOptions.provider;
return await provider.resolve(text, type);
}
function insertAutoMacro(slice, macro, view, from, to) {
if (view) {
// insert the text or linkified/md-converted clipboard data
const selection = view.state.tr.selection;
let tr;
let before;
if (typeof from === 'number' && typeof to === 'number') {
tr = view.state.tr.replaceRange(from, to, slice);
before = tr.mapping.map(from, -1);
} else {
tr = view.state.tr.replaceSelection(slice);
before = tr.mapping.map(selection.from, -1);
}
view.dispatch(tr);
// replace the text with the macro as a separate transaction
// so the autoconversion generates 2 undo steps
const macroTr = closeHistory(view.state.tr).replaceRangeWith(before, before + slice.size, macro).scrollIntoView();
addLinkMetadata(view.state.selection, macroTr, {
inputMethod: INPUT_METHOD.CLIPBOARD,
cardAction: 'AUTO_CONVERT'
});
view.dispatch(macroTr);
return true;
}
return false;
}
export function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, runMacroAutoConvert, cardsOptions, extensionAutoConverter) {
return (state, dispatch, view) => {
let macro = null;
// try to use auto convert from extension provider first
if (extensionAutoConverter) {
const extension = extensionAutoConverter(text);
if (extension) {
macro = PMNode.fromJSON(state.schema, extension);
}
}
// then try from macro provider (which will be removed some time in the future)
if (!macro) {
var _runMacroAutoConvert;
macro = (_runMacroAutoConvert = runMacroAutoConvert === null || runMacroAutoConvert === void 0 ? void 0 : runMacroAutoConvert(state, text)) !== null && _runMacroAutoConvert !== void 0 ? _runMacroAutoConvert : null;
}
if (macro) {
/**
* if FF enabled, run through smart links and check for result
*/
if (cardsOptions && cardsOptions.resolveBeforeMacros && cardsOptions.resolveBeforeMacros.length) {
if (cardsOptions.resolveBeforeMacros.indexOf(macro.attrs.extensionKey) < 0) {
return insertAutoMacro(slice, macro, view);
}
if (!view) {
throw new Error('View is missing');
}
// eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
const trackingId = uuid();
const trackingFrom = `handleMacroAutoConvert-from-${trackingId}`;
const trackingTo = `handleMacroAutoConvert-to-${trackingId}`;
startTrackingPastedMacroPositions({
[trackingFrom]: state.selection.from,
[trackingTo]: state.selection.to
})(state, dispatch);
getSmartLinkAdf(text, 'inline', cardsOptions).then(() => {
// we use view.state rather than state because state becomes a stale
// state reference after getSmartLinkAdf's async work
const {
pastedMacroPositions
} = getPastePluginState(view.state);
if (dispatch) {
handleMarkdown(slice, queueCardsFromChangedTr, pastedMacroPositions[trackingFrom], pastedMacroPositions[trackingTo])(view.state, dispatch);
}
}).catch(() => {
const {
pastedMacroPositions
} = getPastePluginState(view.state);
insertAutoMacro(slice, macro, view, pastedMacroPositions[trackingFrom], pastedMacroPositions[trackingTo]);
}).finally(() => {
stopTrackingPastedMacroPositions([trackingFrom, trackingTo])(view.state, dispatch);
});
return true;
}
return insertAutoMacro(slice, macro, view);
}
return !!macro;
};
}
export function handleCodeBlock(text) {
return (state, dispatch) => {
const {
codeBlock
} = state.schema.nodes;
if (text && hasParentNodeOfType(codeBlock)(state.selection)) {
const tr = closeHistory(state.tr);
tr.scrollIntoView();
if (dispatch) {
dispatch(tr.insertText(text));
}
return true;
}
return false;
};
}
function isOnlyMedia(state, slice) {
const {
media
} = state.schema.nodes;
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return slice.content.childCount === 1 && slice.content.firstChild.type === media;
}
function isOnlyMediaSingle(state, slice) {
const {
mediaSingle
} = state.schema.nodes;
return (
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mediaSingle && slice.content.childCount === 1 && slice.content.firstChild.type === mediaSingle
);
}
export function handleMediaSingle(inputMethod, insertMediaAsMediaSingle) {
return function (slice) {
return (state, dispatch, view) => {
if (view) {
if (isOnlyMedia(state, slice)) {
var _insertMediaAsMediaSi;
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return (_insertMediaAsMediaSi = insertMediaAsMediaSingle === null || insertMediaAsMediaSingle === void 0 ? void 0 : insertMediaAsMediaSingle(view, slice.content.firstChild, inputMethod)) !== null && _insertMediaAsMediaSi !== void 0 ? _insertMediaAsMediaSi : false;
}
if (insideTable(state) && isOnlyMediaSingle(state, slice)) {
const tr = state.tr.replaceSelection(slice);
const nextPos = tr.doc.resolve(tr.mapping.map(state.selection.$from.pos));
if (dispatch) {
dispatch(tr.setSelection(new GapCursorSelection(nextPos, Side.RIGHT)));
}
return true;
}
}
return false;
};
};
}
const hasTopLevelExpand = slice => {
let hasExpand = false;
slice.content.forEach(node => {
if (node.type.name === 'expand' || node.type.name === 'nestedExpand') {
hasExpand = true;
}
});
return hasExpand;
};
export function handleTableContentPasteInBodiedExtension(slice) {
return (state, dispatch) => {
const isInsideBodyExtension = hasParentNodeOfType(state.schema.nodes.bodiedExtension)(state.selection);
if (!insideTable(state) || !isInsideBodyExtension) {
return false;
}
const {
bodiedExtension
} = state.schema.nodes;
const newSlice = mapSlice(slice, maybeNode => {
if (maybeNode.type === bodiedExtension) {
return bodiedExtension.createChecked(maybeNode.attrs, maybeNode.content, maybeNode.marks);
}
return maybeNode;
});
if (dispatch) {
dispatch(state.tr.replaceSelection(newSlice));
return true;
}
return false;
};
}
export function handleNestedTablePaste(slice, isNestingTablesSupported) {
return (state, dispatch) => {
if (!isNestingTablesSupported || !insideTable(state)) {
return false;
}
const {
schema,
selection
} = state;
let sliceHasTable = false;
slice.content.forEach(node => {
if (node.type === state.schema.nodes.table) {
sliceHasTable = true;
}
});
if (sliceHasTable) {
// if slice has table - if pasting to deeply nested location place paste after top table
if (getParentOfTypeCount(schema.nodes.table)(selection.$from) > 1) {
const positionAfterTopTable = getPositionAfterTopParentNodeOfType(schema.nodes.table)(selection.$from);
let {
tr
} = state;
tr = safeInsert(slice.content, positionAfterTopTable)(tr);
tr.scrollIntoView();
if (dispatch) {
dispatch(tr);
return true;
}
}
}
return false;
};
}
export function handleExpandPaste(slice) {
return (state, dispatch) => {
const isInsideNestableExpand = !!insideExpand(state);
// Do not handle expand if it's not being pasted into a table or expand
// OR if it's nested within another node when being pasted into a table/expand
if (!insideTable(state) && !isInsideNestableExpand || !hasTopLevelExpand(slice)) {
return false;
}
const {
expand,
nestedExpand
} = state.schema.nodes;
let {
tr
} = state;
let hasExpand = false;
const newSlice = mapSlice(slice, maybeNode => {
if (maybeNode.type === expand || maybeNode.type === nestedExpand) {
hasExpand = true;
try {
return nestedExpand.createChecked(maybeNode.attrs, maybeNode.content, maybeNode.marks);
} catch {
tr = safeInsert(maybeNode, tr.selection.$to.pos)(tr);
return Fragment.empty;
}
}
return maybeNode;
});
if (hasExpand && dispatch) {
// If the slice is a subset, we can let PM replace the selection
// it will insert as text where it can't place the node.
// Otherwise we use safeInsert to insert below instead of
// replacing/splitting the current node.
if (slice.openStart > 1 && slice.openEnd > 1) {
dispatch(tr.replaceSelection(newSlice));
} else {
dispatch(safeInsert(newSlice.content)(tr));
}
return true;
}
return false;
};
}
export function handleMarkdown(markdownSlice, queueCardsFromChangedTr, from, to) {
return (state, dispatch) => {
const tr = closeHistory(state.tr);
const pastesFrom = typeof from === 'number' ? from : tr.selection.from;
if (typeof from === 'number' && typeof to === 'number') {
tr.replaceRange(from, to, markdownSlice);
} else {
tr.replaceSelection(markdownSlice);
}
const textPosition = tr.doc.resolve(Math.min(pastesFrom + markdownSlice.size, tr.doc.content.size));
tr.setSelection(TextSelection.near(textPosition, -1));
queueCardsFromChangedTr === null || queueCardsFromChangedTr === void 0 ? void 0 : queueCardsFromChangedTr(state, tr, INPUT_METHOD.CLIPBOARD);
if (dispatch) {
dispatch(tr.scrollIntoView());
}
return true;
};
}
function removePrecedingBackTick(tr) {
const {
$from: {
nodeBefore
},
from
} = tr.selection;
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (nodeBefore && nodeBefore.isText && nodeBefore.text.endsWith('`')) {
tr.delete(from - 1, from);
}
}
function hasInlineCode(state, slice) {
return slice.content.firstChild && slice.content.firstChild.marks.some(m => m.type === state.schema.marks.code);
}
function rollupLeafListItems(list, leafListItems) {
list.content.forEach(child => {
if (isListNode(child) || isListItemNode(child) && isListNode(child.firstChild)) {
rollupLeafListItems(child, leafListItems);
} else {
leafListItems.push(child);
}
});
}
function shouldFlattenList(state, slice) {
const node = slice.content.firstChild;
return node && insideTable(state) && isListNode(node) && slice.openStart > slice.openEnd;
}
function sliceHasTopLevelMarks(slice) {
let hasTopLevelMarks = false;
slice.content.descendants(node => {
if (node.marks.length > 0) {
hasTopLevelMarks = true;
}
return false;
});
return hasTopLevelMarks;
}
function getTopLevelMarkTypesInSlice(slice) {
const markTypes = new Set();
slice.content.descendants(node => {
node.marks.map(mark => mark.type).forEach(markType => markTypes.add(markType));
return false;
});
return markTypes;
}
/**
* Peels container wrapper nodes (e.g. panel, expand) added by ProseMirror's addContext()
* so that fontSize-marked paragraphs become top-level, preserving the mark on paste.
*/
function unwrapContainerNodesWithBlockMarks(slice, schema, fontSize) {
let content = slice.content;
let levelsUnwrapped = 0;
while (content.childCount === 1 && content.firstChild && !content.firstChild.isTextblock && slice.openStart - levelsUnwrapped > 1) {
let hasBlockMarkedParagraph = false;
for (let i = 0; i < content.firstChild.childCount; i++) {
const child = content.firstChild.child(i);
if (child.type === schema.nodes.paragraph && child.marks.some(m => m.type === fontSize)) {
hasBlockMarkedParagraph = true;
break;
}
}
if (!hasBlockMarkedParagraph) {
break;
}
content = content.firstChild.content;
levelsUnwrapped++;
}
if (levelsUnwrapped === 0) {
return slice;
}
return new Slice(content, slice.openStart - levelsUnwrapped, Math.max(0, slice.openEnd - levelsUnwrapped));
}
/**
* Returns the fontSize attrs to apply at the paste destination, or false if none.
* Checks list and task destinations in priority order.
*/
function getDestinationFontSizeAttrs(destinationListNode, isInSmallTaskContext, $from, currentNode, fontSize) {
if (destinationListNode) {
return getFirstParagraphBlockMarkAttrs(destinationListNode, fontSize);
}
if (isInSmallTaskContext) {
return getBlockMarkAttrs($from.parent, fontSize) || getFirstParagraphBlockMarkAttrs(currentNode, fontSize);
}
return false;
}
/**
* Resolves which marks to apply to a paragraph node after filtering forbidden marks.
* When a destination block mark is provided, replaces any existing fontSize mark with it.
* When normalizing for the target context, removes the fontSize mark entirely.
* Otherwise returns the filtered marks unchanged.
*/
function resolveParagraphMarks(marks, destinationBlockMarkAttrs, shouldNormalize, fontSize) {
if (destinationBlockMarkAttrs) {
return marks.filter(m => m.type !== fontSize).concat(fontSize.create(destinationBlockMarkAttrs));
}
if (shouldNormalize) {
return marks.filter(m => m.type !== fontSize);
}
return marks;
}
export function handleParagraphBlockMarks(state, slice) {
var _findParentNodeOfType2;
if (slice.content.size === 0) {
return slice;
}
const {
schema,
selection,
selection: {
$from
}
} = state;
const {
bulletList,
orderedList,
blockTaskItem,
taskItem,
paragraph,
heading
} = schema.nodes;
const {
fontSize
} = schema.marks;
const isSmallFontSizeEnabled = !!fontSize && expValEquals('platform_editor_small_font_size', 'isEnabled', true);
// When copying from inside a container (e.g. panel, expand), ProseMirror wraps the
// content back in the container via addContext(), increasing openStart/openEnd. Unwrap
// so the paragraph (with its fontSize mark) becomes top-level.
if (isSmallFontSizeEnabled) {
slice = unwrapContainerNodesWithBlockMarks(slice, schema, fontSize);
}
const destinationListNode = (_findParentNodeOfType2 = findParentNodeOfType([bulletList, orderedList])(selection)) === null || _findParentNodeOfType2 === void 0 ? void 0 : _findParentNodeOfType2.node;
const currentNode = typeof $from.node === 'function' ? $from.node() : undefined;
const isInNormalTaskContext = (currentNode === null || currentNode === void 0 ? void 0 : currentNode.type) === taskItem || $from.parent.type === taskItem;
const isInSmallTaskContext = !!blockTaskItem && ((currentNode === null || currentNode === void 0 ? void 0 : currentNode.type) === blockTaskItem || $from.parent.type === blockTaskItem || $from.parent.type === paragraph && $from.depth > 0 && $from.node($from.depth - 1).type === blockTaskItem);
const destinationBlockMarkAttrs = isSmallFontSizeEnabled ? getDestinationFontSizeAttrs(destinationListNode, isInSmallTaskContext, $from, currentNode, fontSize) : false;
const isInHeadingContext = $from.parent.type === heading;
// If no paragraph in the slice contains marks, there's no need for special handling
// unless we're pasting into a small-text list and need to add the destination block mark.
// Note: this doesn't check for marks applied to lower level nodes such as text
if (!sliceHasTopLevelMarks(slice) && !destinationBlockMarkAttrs) {
return slice;
}
const shouldNormalizeFontSizeForTarget = isSmallFontSizeEnabled && (!!destinationListNode || isInNormalTaskContext || isInSmallTaskContext || isInHeadingContext);
// If pasting a single paragraph into pre-existing content, match destination formatting.
// For bullet/ordered lists under small-text, we still need to normalize the paragraph block mark
// so pasted content adopts the destination list state.
const destinationHasContent = $from.parent.textContent.length > 0;
if (slice.content.childCount === 1 && destinationHasContent && !shouldNormalizeFontSizeForTarget) {
return slice;
}
// Check the parent of (paragraph -> text) because block marks are assigned to a wrapper
// element around the paragraph node
const grandparent = $from.node(Math.max(0, $from.depth - 1));
const markTypesInSlice = getTopLevelMarkTypesInSlice(slice);
const forbiddenMarkTypes = [];
for (const markType of markTypesInSlice) {
if (!grandparent.type.allowsMarkType(markType)) {
forbiddenMarkTypes.push(markType);
}
}
const normalizedContent = mapSlice(slice, node => {
if (node.type === paragraph) {
const paragraphMarks = node.marks.filter(mark => !forbiddenMarkTypes.includes(mark.type));
return paragraph.createChecked(undefined, node.content, resolveParagraphMarks(paragraphMarks, destinationBlockMarkAttrs, shouldNormalizeFontSizeForTarget, fontSize));
} else if (node.type === heading) {
// Preserve heading attributes to keep formatting
return heading.createChecked(node.attrs, node.content, node.marks.filter(mark => !forbiddenMarkTypes.includes(mark.type)));
}
return node;
});
if (forbiddenMarkTypes.length === 0 && !shouldNormalizeFontSizeForTarget) {
// In a slice containing one or more paragraphs at the document level (not wrapped in
// another node), the first paragraph will only have its text content captured and pasted
// since openStart is 1. We decrement the open depth of the slice so it retains any block
// marks applied to it. We only care about the depth at the start of the selection so
// there's no need to change openEnd - the rest of the slice gets pasted correctly.
const openStart = Math.max(0, slice.openStart - 1);
return new Slice(slice.content, openStart, slice.openEnd);
}
if (forbiddenMarkTypes.length === 0 && shouldNormalizeFontSizeForTarget) {
// When pasting into a heading, keep the original openStart so ProseMirror merges inline
// content into the heading node rather than replacing it with a paragraph.
const openStart = isInHeadingContext ? slice.openStart : Math.max(0, slice.openStart - 1);
return new Slice(normalizedContent.content, openStart, slice.openEnd);
}
// If the paragraph or heading contains marks forbidden by the parent node
// (e.g. alignment/indentation), drop those marks from the slice. For lists under the small
// text experiment, also normalize fontSize to the destination list state.
return new Slice(normalizedContent.content, slice.openStart, slice.openEnd);
}
/**
* ED-6300: When a nested list is pasted in a table cell and the slice has openStart > openEnd,
* it splits the table. As a workaround, we flatten the list to even openStart and openEnd.
*
* Note: this only happens if the first child is a list
*
* Example: copying "one" and "two"
* - zero
* - one
* - two
*
* Before:
* ul
* ┗━ li
* ┗━ ul
* ┗━ li
* ┗━ p -> "one"
* ┗━ li
* ┗━ p -> "two"
*
* After:
* ul
* ┗━ li
* ┗━ p -> "one"
* ┗━ li
* ┗━p -> "two"
*/
export function flattenNestedListInSlice(slice) {
if (!slice.content.firstChild) {
return slice;
}
const listToFlatten = slice.content.firstChild;
const leafListItems = [];
rollupLeafListItems(listToFlatten, leafListItems);
const contentWithFlattenedList = slice.content.replaceChild(0, listToFlatten.type.createChecked(listToFlatten.attrs, leafListItems));
return new Slice(contentWithFlattenedList, slice.openEnd, slice.openEnd);
}
const doesSliceContainBlockquoteListNodes = (slice, listContainerNodeTypes) => {
var _firstChildOfSlice$ty, _lastChildOfSlice$typ;
const firstChildOfSlice = slice.content.firstChild;
const lastChildOfSlice = slice.content.lastChild;
const isFirstChildBlockquoteListNode = (firstChildOfSlice === null || firstChildOfSlice === void 0 ? void 0 : (_firstChildOfSlice$ty = firstChildOfSlice.type) === null || _firstChildOfSlice$ty === void 0 ? void 0 : _firstChildOfSlice$ty.name) === 'blockquote' && listContainerNodeTypes.some(nodeType => {
var _firstChildOfSlice$co;
return nodeType === (firstChildOfSlice === null || firstChildOfSlice === void 0 ? void 0 : (_firstChildOfSlice$co = firstChildOfSlice.content.firstChild) === null || _firstChildOfSlice$co === void 0 ? void 0 : _firstChildOfSlice$co.type);
});
const isLastChildBlockquoteListNode = (lastChildOfSlice === null || lastChildOfSlice === void 0 ? void 0 : (_lastChildOfSlice$typ = lastChildOfSlice.type) === null || _lastChildOfSlice$typ === void 0 ? void 0 : _lastChildOfSlice$typ.name) === 'blockquote' && listContainerNodeTypes.some(nodeType => {
var _lastChildOfSlice$con;
return nodeType === (lastChildOfSlice === null || lastChildOfSlice === void 0 ? void 0 : (_lastChildOfSlice$con = lastChildOfSlice.content.firstChild) === null || _lastChildOfSlice$con === void 0 ? void 0 : _lastChildOfSlice$con.type);
});
return isFirstChildBlockquoteListNode || isLastChildBlockquoteListNode;
};
export function handleRichText(slice, queueCardsFromChangedTr) {
return (state, dispatch) => {
var _slice$content, _slice$content2, _findParentNodeOfType3, _firstChildOfSlice$ty2, _lastChildOfSlice$typ2, _panelParentOverCurre;
const {
codeBlock,
heading,
paragraph,
panel,
bulletList,
orderedList
} = state.schema.nodes;
const {
fontSize
} = state.schema.marks;
const {
selection,
schema
}