UNPKG

@atlaskit/editor-plugin-paste

Version:

Paste plugin for @atlaskit/editor-core

1,123 lines (1,073 loc) 63.3 kB
// 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 }