UNPKG

@atlaskit/editor-plugin-card

Version:

Card plugin for @atlaskit/editor-core

596 lines (584 loc) 21.9 kB
import isEqual from 'lodash/isEqual'; import { isSafeUrl } from '@atlaskit/adf-schema'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD, SMART_LINK_TYPE, unlinkPayload } from '@atlaskit/editor-common/analytics'; import { addLinkMetadata } from '@atlaskit/editor-common/card'; import { getActiveLinkMark } from '@atlaskit/editor-common/link'; import { getAnnotationMarksForPos, getLinkCreationAnalyticsEvent, isFromCurrentDomain, nodesBetweenChanged, processRawValue } from '@atlaskit/editor-common/utils'; import { NodeSelection, TextSelection } from '@atlaskit/editor-prosemirror/state'; import { closeHistory } from '@atlaskit/prosemirror-history'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { hideDatasourceModal, queueCards, removeDatasourceStash, resolveCard, setDatasourceStash } from './actions'; import { pluginKey } from './plugin-key'; import { shouldReplaceLink } from './shouldReplaceLink'; import { appearanceForNodeType, isDatasourceConfigEditable, isDatasourceNode, selectedCardAppearance } from './utils'; /** * Attempt to replace the link into the respective card. */ function replaceLinksToCards(tr, cardAdf, schema, request) { const { inlineCard } = schema.nodes; const { url } = request; if (!isSafeUrl(url)) { return; } // replace all the outstanding links with their cards const pos = tr.mapping.map(request.pos); const $pos = tr.doc.resolve(pos); const $head = tr.selection.$head; const node = tr.doc.nodeAt(pos); if (!node || !node.type.isText) { return; } const replaceLink = request.shouldReplaceLink || shouldReplaceLink(node, request.compareLinkText, url); if (!replaceLink) { return; } // ED-5638: add an extra space after inline cards to avoid re-rendering them const nodes = [cardAdf]; if (cardAdf.type === inlineCard) { nodes.push(schema.text(' ')); } tr.replaceWith(pos, pos + (node.text || url).length, nodes); const annotationMarksForPos = getAnnotationMarksForPos($head); if (annotationMarksForPos && annotationMarksForPos.length > 0) { annotationMarksForPos.forEach(annotationMark => { // Add the annotation mark on to the inlineCard node and the trailing space node. tr.addMark(pos, pos + nodes[0].nodeSize + nodes[1].nodeSize, annotationMark); }); } return $pos.node($pos.depth - 1).type.name; } export const replaceQueuedUrlWithCard = (url, cardData, analyticsAction, editorAnalyticsApi, createAnalyticsEvent, embedCardNodeTransformer) => (editorState, dispatch) => { const state = pluginKey.getState(editorState); if (!state) { return false; } // find the requests for this URL const requests = state.requests.filter(req => req.url === url); // try to transform response to ADF const schema = editorState.schema; let cardAdf = null; // If an embed card transformer is provided and the resolved card is an embedCard, // attempt to transform it into an alternative node representation first. if (cardData.type === 'embedCard' && embedCardNodeTransformer) { var _embedCardNodeTransfo; cardAdf = (_embedCardNodeTransfo = embedCardNodeTransformer(schema, cardData.attrs)) !== null && _embedCardNodeTransfo !== void 0 ? _embedCardNodeTransfo : null; } if (!cardAdf) { var _processRawValue; cardAdf = (_processRawValue = processRawValue(schema, cardData)) !== null && _processRawValue !== void 0 ? _processRawValue : null; } const tr = editorState.tr; if (cardAdf) { // Should prevent any other node than cards? [inlineCard, blockCard].includes(cardAdf.type) const nodeContexts = requests.map(request => replaceLinksToCards(tr, cardAdf, schema, request)).filter(context => !!context); // context exist // Send analytics information if (nodeContexts.length) { const nodeContext = nodeContexts.every(context => context === nodeContexts[0]) ? nodeContexts[0] : 'mixed'; /** For block links v1, default to inline links */ const nodeType = 'inlineCard'; const [,, domainName] = url.split('/'); if (state.smartLinkEvents) { state.smartLinkEvents.insertSmartLink(domainName, 'inline', createAnalyticsEvent); } /** * TODO: * What if each request has a different source? * Unlikely, but need to define behaviour. * Ignore analytics event? take first? provide 'mixed' as well? */ const inputMethod = requests[0].source; const sourceEvent = requests[0].sourceEvent; editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent({ // eslint-disable-next-line @typescript-eslint/no-explicit-any action: analyticsAction || ACTION.INSERTED, actionSubject: ACTION_SUBJECT.DOCUMENT, actionSubjectId: ACTION_SUBJECT_ID.SMART_LINK, eventType: EVENT_TYPE.TRACK, attributes: { inputMethod, nodeType, nodeContext: nodeContext, fromCurrentDomain: isFromCurrentDomain(url) }, nonPrivacySafeAttributes: { domainName } })(tr); addLinkMetadata(editorState.selection, tr, { action: analyticsAction, inputMethod, cardAction: 'RESOLVE', sourceEvent }); } } if (dispatch) { dispatch(resolveCard(url)(closeHistory(tr))); } return true; }; export const handleFallbackWithAnalytics = (request, editorAnalyticsApi) => (state, dispatch) => { const cardState = pluginKey.getState(state); if (!cardState) { return false; } const tr = state.tr; if (request.source !== INPUT_METHOD.FLOATING_TB) { editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent(getLinkCreationAnalyticsEvent(request.source, request.url))(tr); } addLinkMetadata(state.selection, tr, { action: request.analyticsAction, inputMethod: request.source, sourceEvent: request.sourceEvent }); if (dispatch) { dispatch(resolveCard(request.url)(tr)); } return true; }; export const queueCardsFromChangedTr = (state, tr, source, analyticsAction, normalizeLinkText = true, sourceEvent = undefined, appearance = 'inline') => { const { schema } = state; const { link } = schema.marks; const requests = []; nodesBetweenChanged(tr, (node, pos) => { if (!node.isText) { return true; } const linkMark = node.marks.find(mark => mark.type === link); if (linkMark) { if (!shouldReplaceLink(node, normalizeLinkText)) { return false; } requests.push({ url: linkMark.attrs.href, pos, appearance, compareLinkText: normalizeLinkText, source, analyticsAction, sourceEvent }); } return false; }); if (analyticsAction) { addLinkMetadata(state.selection, tr, { action: analyticsAction }); } return queueCards(requests)(tr); }; export const queueCardFromChangedTr = (state, tr, source, analyticsAction, normalizeLinkText = true, sourceEvent = undefined, previousAppearance) => { const { schema } = state; const { link } = schema.marks; const requests = []; nodesBetweenChanged(tr, (node, pos) => { if (!node.isText) { return true; } const linkMark = node.marks.find(mark => mark.type === link); if (linkMark) { if (!shouldReplaceLink(node, normalizeLinkText)) { return false; } requests.push({ url: linkMark.attrs.href, pos, appearance: 'inline', previousAppearance: previousAppearance, compareLinkText: normalizeLinkText, source, analyticsAction, sourceEvent }); } return false; }); addLinkMetadata(state.selection, tr, { action: analyticsAction }); return queueCards(requests)(tr); }; export const convertHyperlinkToSmartCard = (state, source, appearance, normalizeLinkText = true) => { const { schema } = state; const { link } = schema.marks; const requests = []; const createRequest = (linkMark, pos) => ({ url: linkMark.attrs.href, pos, appearance, previousAppearance: 'url', compareLinkText: normalizeLinkText, source, analyticsAction: ACTION.CHANGED_TYPE, shouldReplaceLink: true }); if (editorExperiment('platform_editor_controls', 'variant1')) { const activeLinkMark = getActiveLinkMark(state); if (activeLinkMark) { const linkMark = activeLinkMark.node.marks.find(mark => mark.type === link); if (linkMark) { requests.push(createRequest(linkMark, activeLinkMark.pos)); } } } else { state.tr.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos) => { const linkMark = node.marks.find(mark => mark.type === link); if (linkMark) { requests.push(createRequest(linkMark, pos)); } }); } addLinkMetadata(state.selection, state.tr, { action: ACTION.CHANGED_TYPE }); return queueCards(requests)(state.tr); }; export const changeSelectedCardToLink = (text, href, sendAnalytics, node, pos, editorAnalyticsApi) => (state, dispatch) => { const selectedNode = state.selection instanceof NodeSelection ? state.selection.node : undefined; let tr; if (node && pos) { tr = cardNodeToLinkWithTransaction(state, text, href, node, pos); } else { tr = cardToLinkWithTransaction(state, text, href); } updateDatasourceStash(tr, selectedNode); if (sendAnalytics) { if (selectedNode) { editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent({ action: ACTION.CHANGED_TYPE, actionSubject: ACTION_SUBJECT.SMART_LINK, eventType: EVENT_TYPE.TRACK, attributes: { newType: SMART_LINK_TYPE.URL, previousType: appearanceForNodeType(selectedNode.type) } })(tr); } } if (dispatch) { dispatch(tr.scrollIntoView()); } return true; }; export const changeSelectedCardToLinkFallback = (text, href, sendAnalytics, node, pos, editorAnalyticsApi) => (state, dispatch) => { let tr; if (node && pos) { tr = cardNodeToLinkWithTransaction(state, text, href, node, pos); } else { tr = cardToLinkWithTransaction(state, text, href); } if (sendAnalytics) { editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent({ action: ACTION.ERRORED, actionSubject: ACTION_SUBJECT.SMART_LINK, eventType: EVENT_TYPE.OPERATIONAL, attributes: { error: 'Smart card falling back to link.' } })(tr); } if (dispatch) { dispatch(tr.setMeta('addToHistory', false)); } return true; }; export const updateCard = (href, sourceEvent) => (state, dispatch) => { const selectedNode = state.selection instanceof NodeSelection && state.selection.node; if (!selectedNode) { return false; } const cardAppearance = selectedCardAppearance(state); const tr = cardToLinkWithTransaction(state, href, href); queueCardFromChangedTr(state, tr, INPUT_METHOD.MANUAL, ACTION.UPDATED, undefined, sourceEvent, cardAppearance); if (dispatch) { dispatch(tr.scrollIntoView()); } return true; }; function cardToLinkWithTransaction(state, text, href) { const selectedNode = state.selection instanceof NodeSelection && state.selection.node; if (!selectedNode) { return state.tr; } const { link } = state.schema.marks; const url = selectedNode.attrs.url || selectedNode.attrs.data.url; const tr = state.tr.replaceSelectionWith(state.schema.text(text || url, [link.create({ href: href || url })]), false); return tr; } function cardNodeToLinkWithTransaction(state, text, href, node, pos) { const { link } = state.schema.marks; const url = node.attrs.url || node.attrs.data.url; return state.tr.replaceWith(pos, pos + node.nodeSize, state.schema.text(text || url, [link.create({ href: href || url })])); } export const changeSelectedCardToText = (text, editorAnalyticsApi) => (state, dispatch) => { const selectedNode = state.selection instanceof NodeSelection && state.selection.node; if (!selectedNode) { return false; } const tr = state.tr.replaceSelectionWith(state.schema.text(text), false); if (dispatch) { addLinkMetadata(state.selection, tr, { action: ACTION.UNLINK }); tr.scrollIntoView(); editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent(unlinkPayload(ACTION_SUBJECT_ID.CARD_INLINE))(tr); dispatch(tr); } return true; }; export const setSelectedCardAppearance = (appearance, editorAnalyticsApi) => (state, dispatch) => { var _selectedNode$attrs$d, _previousNode$type; const selectedNode = state.selection instanceof NodeSelection ? state.selection.node : undefined; if (!selectedNode) { // When there is no selected node, we insert a new one // and replace the existing blue link const tr = convertHyperlinkToSmartCard(state, INPUT_METHOD.FLOATING_TB, appearance); if (dispatch) { addLinkMetadata(state.selection, tr, { action: ACTION.CHANGED_TYPE }); dispatch(tr.scrollIntoView()); } return false; } if (appearanceForNodeType(selectedNode.type) === appearance && !selectedNode.attrs.datasource) { return false; } const attrs = editorExperiment('platform_synced_block', true) ? getAttrsForAppearance(appearance, selectedNode, state.selection.$from.parent.type.name === 'bodiedSyncBlock') : getAttrsForAppearance(appearance, selectedNode); const { from, to } = state.selection; const nodeType = getLinkNodeType(appearance, state.schema.nodes); const tr = state.tr.setNodeMarkup(from, nodeType, attrs, selectedNode.marks); // If switching to embed appearance, attempt to use a registered transform command // to create an alternative node representation (e.g. a native embed). if (appearance === 'embed' && (selectedNode.attrs.url || (_selectedNode$attrs$d = selectedNode.attrs.data) !== null && _selectedNode$attrs$d !== void 0 && _selectedNode$attrs$d.url)) { var _cardState$embedCardT; const cardState = pluginKey.getState(state); const createEmbedCardTransformCommand = cardState === null || cardState === void 0 ? void 0 : (_cardState$embedCardT = cardState.embedCardTransformers) === null || _cardState$embedCardT === void 0 ? void 0 : _cardState$embedCardT.createEmbedCardTransformCommand; if (createEmbedCardTransformCommand) { const transformCommand = createEmbedCardTransformCommand({ editorAnalyticsApi, augmentTransaction: augmentTr => { updateDatasourceStash(augmentTr, selectedNode); editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent({ action: ACTION.CHANGED_TYPE, actionSubject: ACTION_SUBJECT.SMART_LINK, eventType: EVENT_TYPE.TRACK, attributes: { newType: appearance, previousType: appearanceForNodeType(selectedNode.type) } })(augmentTr); addLinkMetadata(state.selection, augmentTr, { action: ACTION.CHANGED_TYPE }); } }); if (transformCommand(state, dispatch)) { return true; } } } updateDatasourceStash(tr, selectedNode); // When the selected card is the last element in the doc we add a new paragraph after it for consistent replacement if (tr.doc.nodeSize - 2 === to) { tr.insertText(' ', to); } tr.setSelection(TextSelection.create(tr.doc, to + 1)); const previousNodePos = from - 1 > 0 ? from - 1 : 0; const previousNode = tr.doc.nodeAt(previousNodePos); if ((previousNode === null || previousNode === void 0 ? void 0 : (_previousNode$type = previousNode.type) === null || _previousNode$type === void 0 ? void 0 : _previousNode$type.name) === 'paragraph') { tr.delete(previousNodePos, from); } editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent({ action: ACTION.CHANGED_TYPE, actionSubject: ACTION_SUBJECT.SMART_LINK, eventType: EVENT_TYPE.TRACK, attributes: { newType: appearance, previousType: appearanceForNodeType(selectedNode.type) } })(tr); addLinkMetadata(state.selection, tr, { action: ACTION.CHANGED_TYPE }); if (dispatch) { dispatch(tr.scrollIntoView()); } return true; }; export const getLinkNodeType = (appearance, linkNodes) => { switch (appearance) { case 'inline': return linkNodes.inlineCard; case 'block': return linkNodes.blockCard; case 'embed': return linkNodes.embedCard; } }; // Apply an update made from a datasource ui interaction export const updateCardViaDatasource = args => { const { state, node, newAdf, view, sourceEvent, isDeletingConfig, inputMethod } = args; const { tr, selection: { from, to }, schema: { nodes: schemaNodes } } = state; if (newAdf.type === 'blockCard') { var _node$attrs, _newAdf$attrs; if ((_node$attrs = node.attrs) !== null && _node$attrs !== void 0 && _node$attrs.datasource && (_newAdf$attrs = newAdf.attrs) !== null && _newAdf$attrs !== void 0 && _newAdf$attrs.datasource) { var _ref, _ref2, _oldViews$properties, _newViews$properties; const newAttrs = newAdf.attrs; const oldAttrs = node.attrs; const [newViews] = (_ref = newAttrs.datasource.views) !== null && _ref !== void 0 ? _ref : []; const [oldViews] = (_ref2 = oldAttrs.datasource.views) !== null && _ref2 !== void 0 ? _ref2 : []; const isColumnChange = !isEqual(oldViews === null || oldViews === void 0 ? void 0 : (_oldViews$properties = oldViews.properties) === null || _oldViews$properties === void 0 ? void 0 : _oldViews$properties.columns, newViews === null || newViews === void 0 ? void 0 : (_newViews$properties = newViews.properties) === null || _newViews$properties === void 0 ? void 0 : _newViews$properties.columns); const isUrlChange = newAttrs.url !== oldAttrs.url; if (isColumnChange || isUrlChange) { tr.setNodeMarkup(from, schemaNodes.blockCard, { ...oldAttrs, ...newAdf.attrs }); } } else if (node.type.isText) { // url to datasource let link; state.doc.nodesBetween(from, to, (node, pos) => { // get the actual start position of a link within the node const linkMark = node.marks.find(mark => mark.type === state.schema.marks.link); if (linkMark) { link = { url: linkMark.attrs.href, text: node.text, pos }; return false; } return true; }); if (link) { const newNode = schemaNodes.blockCard.createChecked(newAdf.attrs); tr.replaceWith(link.pos, link.pos + (link.text || link.url).length, [newNode]); } } else { // inline or blockCard to datasource tr.setNodeMarkup(from, schemaNodes.blockCard, newAdf.attrs); } } else if (newAdf.type === 'inlineCard') { // card type to inlineCard tr.setNodeMarkup(from, schemaNodes.inlineCard, newAdf.attrs); } addLinkMetadata(state.selection, tr, { action: ACTION.UPDATED, sourceEvent, inputMethod }); if (isDeletingConfig) { if (typeof node.attrs.url === 'string') { removeDatasourceStash(tr, node.attrs.url); } } else { hideDatasourceModal(tr); } view.dispatch(tr.scrollIntoView()); }; export const insertDatasource = (state, adf, view, sourceEvent) => { const { tr, selection: { from }, schema: { nodes: schemaNodes } } = state; const { attrs, type } = adf; const schemaNode = type === 'inlineCard' ? schemaNodes.inlineCard : schemaNodes.blockCard; const newNode = schemaNode.createChecked(attrs); // in future, if we decide to do datasource insertion from the main toolbar, we should probably consider editor-plugin-content-insertion instead of tr.insert // this will allow us to deal with insertions from multiple paths in a more consistent way newNode && tr.insert(from, newNode); hideDatasourceModal(tr); addLinkMetadata(state.selection, tr, { action: ACTION.INSERTED, sourceEvent }); view.dispatch(tr.scrollIntoView()); }; /** * Get attributes for new Card Appearance */ export const getAttrsForAppearance = (appearance, selectedNode, isInsideBodiedSyncBlock = false) => { if (appearance === 'embed') { var _selectedNode$attrs$w; return { ...selectedNode.attrs, layout: 'center', ...(isInsideBodiedSyncBlock ? // When converting to embed, width attribute is set to null and when the document is published, the width attribute is set to 100 as per schema default // For editor, width is not required to render the embed card, but it's required in renderer // Because sync block has nested renderer in editor, we need width to be defined even in editor so embed in reference sync block can be rendered properly { width: (_selectedNode$attrs$w = selectedNode.attrs.width) !== null && _selectedNode$attrs$w !== void 0 ? _selectedNode$attrs$w : 100 } : {}) }; } if (isDatasourceNode(selectedNode)) { return { url: selectedNode.attrs.url }; } return selectedNode.attrs; }; const updateDatasourceStash = (tr, selectedNode) => { if (isDatasourceNode(selectedNode) && !isDatasourceConfigEditable(selectedNode.attrs.datasource.id) && selectedNode.attrs.url) { setDatasourceStash(tr, { url: selectedNode.attrs.url, views: selectedNode.attrs.datasource.views }); } };