@atlaskit/editor-plugin-card
Version:
Card plugin for @atlaskit/editor-core
596 lines (584 loc) • 21.9 kB
JavaScript
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
});
}
};