@atlaskit/editor-plugin-card
Version:
Card plugin for @atlaskit/editor-core
381 lines (367 loc) • 12.7 kB
JavaScript
import { LinkMetaStep } from '@atlaskit/adf-schema/steps';
import { TableSortStep } from '@atlaskit/custom-steps';
import { ACTION } from '@atlaskit/editor-common/analytics';
import { getLinkMetadataFromTransaction } from '@atlaskit/editor-common/card';
import { isLinkMark, pmHistoryPluginKey } from '@atlaskit/editor-common/utils';
import { AddMarkStep, RemoveMarkStep } from '@atlaskit/editor-prosemirror/transform';
import { pluginKey } from '../../pm-plugins/plugin-key';
import { getPluginState } from '../../pm-plugins/util/state';
import { EVENT, EVENT_SUBJECT } from './types';
import { appearanceForLink, areSameNodes, findAtPositions, findInNodeRange, getNodeContext, getNodeSubject } from './utils';
/**
* Find the links, smartLinks, datasources that were changed in a transaction
*/
export const findChanged = (tr, state) => {
const schema = tr.doc.type.schema;
const removed = [];
const inserted = [];
/**
* Ideally we have the "before" and "after" states of the "entity"
* being updated, but if the update is via a "queue for upgrade"
* then we no longer have access to the "before" state, because the "before"
* state was replaced with a blue link to be upgraded
*/
const updated = [];
const queuedForUpgrade = isTransactionQueuedForUpgrade(tr);
const isResolveReplace = isTransactionResolveReplace(tr);
const isAutoConvert = isAutoConvertTr(tr);
// History
const historyMeta = tr.getMeta(pmHistoryPluginKey);
const isUndo = isHistoryMeta(historyMeta) && historyMeta.redo === false;
const isRedo = isHistoryMeta(historyMeta) && historyMeta.redo === true;
const isUpdate = isUpdateTr(tr, isUndo || isRedo);
for (let i = 0; i < tr.steps.length; i++) {
var _tr$docs$i, _tr$docs;
const step = tr.steps[i];
const stepMap = step.getMap();
const removedInStep = [];
const insertedInStep = [];
const before = (_tr$docs$i = tr.docs[i]) !== null && _tr$docs$i !== void 0 ? _tr$docs$i : tr.before;
const after = (_tr$docs = tr.docs[i + 1]) !== null && _tr$docs !== void 0 ? _tr$docs : tr.doc;
/**
* AddMarkStep and RemoveMarkSteps don't produce stepMap ranges
* because there are no "changed tokens" only marks added/removed
* So have to check these manually
*/
if (step instanceof AddMarkStep) {
const addMarkStep = step;
if (isLinkMark(addMarkStep.mark, schema)) {
const node = after.nodeAt(addMarkStep.from);
if (node) {
/**
* For url text pasted on plain text
*/
insertedInStep.push({
pos: addMarkStep.from,
node,
nodeContext: getNodeContext(after, addMarkStep.from)
});
}
}
}
if (step instanceof RemoveMarkStep) {
const removeMarkStep = step;
if (isLinkMark(removeMarkStep.mark, schema)) {
const node = before.nodeAt(removeMarkStep.from);
if (node) {
removedInStep.push({
pos: removeMarkStep.from,
node,
nodeContext: getNodeContext(before, removeMarkStep.from)
});
}
}
}
stepMap.forEach((oldStart, oldEnd, newStart, newEnd) => {
var _tr$docs2;
const before = tr.docs[i];
const after = (_tr$docs2 = tr.docs[i + 1]) !== null && _tr$docs2 !== void 0 ? _tr$docs2 : tr.doc;
const removedInRange = [];
const insertedInRange = [];
// Removed
removedInRange.push(...findInNodeRange(before, oldStart, oldEnd, node => !!getNodeSubject(node)));
// Inserted
insertedInRange.push(...findInNodeRange(after, newStart, newEnd, node => !!getNodeSubject(node)));
removedInStep.push(...removedInRange);
insertedInStep.push(...insertedInRange);
});
const omitRequestsForUpgrade = links => {
if (!queuedForUpgrade) {
return links;
}
/**
* Skip/filter out links that have been queued, they will be tracked later
*/
const queuedPositions = getQueuedPositions(tr);
return links.filter(link => !queuedPositions.includes(link.pos));
};
/**
* Skip "deletions" when the transaction is relating to
* replacing links queued for upgrade to cards,
* because the "deleted" link has not actually been
* tracked as "created" yet.
* Also skip when the transaction is an auto-convert
* (e.g. a pasted link being converted to a native embed extension),
* because the link is being converted, not deleted by the user.
*/
if (!isResolveReplace && !isAutoConvert) {
removed.push(...removedInStep);
}
inserted.push(...omitRequestsForUpgrade(insertedInStep));
}
/**
* If there are no links changed but the transaction is a "resolve" action
* Then this means we have resolved a link but it has failed to upgrade
* We should track all resolved links as now being created
*/
if (inserted.length === 0 && isResolveReplace) {
const positions = getResolvePositions(tr, state);
inserted.push(...findAtPositions(tr, positions));
}
if (!isUpdate) {
const {
inputMethod
} = getLinkMetadataFromTransaction(tr);
/**
* If there is no identifiable input method, and the links inserted and removed appear to be the same,
* then this transaction likely is not intended to be considered to be the insertion and removal of links
*/
if (!inputMethod && areSameNodes(removed, inserted)) {
return {
removed: [],
inserted: [],
updated
};
}
return {
removed,
inserted,
updated
};
}
const updateInserted = [];
const updateRemoved = [];
for (let i = 0; i < inserted.length; i++) {
if (isResolveReplace) {
const newLink = inserted[i];
// what is the 2nd argument 'assoc = -1' doing here exactly?
const mappedPos = tr.mapping.map(newLink.pos, -1);
const previousDisplay = getResolveLinkPrevDisplay(state, mappedPos);
updated.push({
inserted: inserted[i],
previous: {
display: previousDisplay
}
});
continue;
}
if (inserted.length === removed.length) {
const previousSubject = getNodeSubject(removed[i].node);
const currentSubject = getNodeSubject(inserted[i].node);
if (isDatasourceUpgrade(previousSubject, currentSubject) || isDatasourceDowngrade(previousSubject, currentSubject)) {
updateInserted.push(inserted[i]);
updateRemoved.push(removed[i]);
} else {
updated.push({
removed: removed[i],
inserted: inserted[i]
});
}
}
}
return {
inserted: updateInserted,
removed: updateRemoved,
updated
};
};
/**
* List of actions to be considered link "updates"
*/
const UPDATE_ACTIONS = [ACTION.CHANGED_TYPE, ACTION.UPDATED];
/**
* Returns true if the transaction has LinkMetaSteps that indicate the transaction is
* intended to be perceived as an update to links, rather than insertion+deletion
*/
const isUpdateTr = (tr, isUndoOrRedo) => {
return !!tr.steps.find(step => {
if (!(step instanceof LinkMetaStep)) {
return false;
}
const {
action,
cardAction
} = step.getMetadata();
/**
* Undo of a resolve step should be considered an update
* because the user is choosing to update the url back to the un-upgraded display
*/
if (cardAction === 'RESOLVE' && isUndoOrRedo) {
return true;
}
if (!action) {
return false;
}
return UPDATE_ACTIONS.includes(action);
});
};
const hasType = pluginMeta => {
return typeof pluginMeta === 'object' && pluginMeta !== null && 'type' in pluginMeta;
};
const isTransactionQueuedForUpgrade = tr => {
const pluginMeta = tr.getMeta(pluginKey);
return isMetadataQueue(pluginMeta);
};
const isMetadataQueue = metaData => {
return hasType(metaData) && metaData.type === 'QUEUE';
};
const isTransactionResolveReplace = tr => {
const pluginMeta = tr.getMeta(pluginKey);
return isMetadataResolve(pluginMeta);
};
/**
* Checks if the transaction is an auto-convert action
* (e.g. a pasted link being converted to a native embed extension node).
* In this case the link removal should not be tracked as a deletion.
*/
const isAutoConvertTr = tr => {
return !!tr.steps.find(step => {
if (!(step instanceof LinkMetaStep)) {
return false;
}
return step.getMetadata().cardAction === 'AUTO_CONVERT';
});
};
const isMetadataResolve = metaData => {
return hasType(metaData) && metaData.type === 'RESOLVE';
};
const isHistoryMeta = meta => {
return typeof meta === 'object' && meta !== null && 'redo' in meta;
};
const getQueuedPositions = tr => {
const pluginMeta = tr.getMeta(pluginKey);
if (!isMetadataQueue(pluginMeta)) {
return [];
}
return pluginMeta.requests.map(({
pos
}) => pos);
};
const getResolvePositions = (tr, state) => {
const cardState = getPluginState(state);
if (!cardState) {
return [];
}
const pluginMeta = tr.getMeta(pluginKey);
if (!isMetadataResolve(pluginMeta)) {
return [];
}
return cardState.requests.filter(request => request.url === pluginMeta.url).map(request => request.pos);
};
const getResolveLinkPrevDisplay = (state, pos) => {
var _cardState$requests$f;
const cardState = getPluginState(state);
if (!cardState) {
return undefined;
}
return (_cardState$requests$f = cardState.requests.find(request => request.pos === pos)) === null || _cardState$requests$f === void 0 ? void 0 : _cardState$requests$f.previousAppearance;
};
const isDatasourceDowngrade = (previousSubject, currentSubject) => previousSubject === EVENT_SUBJECT.DATASOURCE && currentSubject === EVENT_SUBJECT.LINK;
const isDatasourceUpgrade = (previousSubject, currentSubject) => previousSubject === EVENT_SUBJECT.LINK && currentSubject === EVENT_SUBJECT.DATASOURCE;
export function eventsFromTransaction(tr, state) {
const events = [];
try {
/**
* Skip transactions sent by collab (identified by 'isRemote' key)
* Skip entire document replace steps
* We are only concerned with transactions performed on the document directly by the user
*/
const isRemote = tr.getMeta('isRemote');
const isReplaceDocument = tr.getMeta('replaceDocument');
const isTableSort = tr.steps.find(step => step instanceof TableSortStep);
if (isRemote || isReplaceDocument || isTableSort) {
return events;
}
const historyMeta = tr.getMeta(pmHistoryPluginKey);
const isUndo = isHistoryMeta(historyMeta) && historyMeta.redo === false;
const isRedo = isHistoryMeta(historyMeta) && historyMeta.redo === true;
/**
* Retrieve metadata from the LinkMetaStep(s) in the transaction
*/
const {
action,
inputMethod,
sourceEvent
} = getLinkMetadataFromTransaction(tr);
const {
removed,
inserted,
updated
} = findChanged(tr, state);
const MAX_LINK_EVENTS = 50;
if ([removed, inserted, updated].some(arr => arr.length > MAX_LINK_EVENTS)) {
return [];
}
for (let i = 0; i < updated.length; i++) {
var _update$previous$disp;
const update = updated[i];
const {
inserted
} = update;
const {
node,
nodeContext
} = inserted;
const subject = getNodeSubject(node);
/**
* Not great, wish we had the previous node but we never stored it
*/
const previousDisplay = 'removed' in update ? appearanceForLink(update.removed.node) : (_update$previous$disp = update.previous.display) !== null && _update$previous$disp !== void 0 ? _update$previous$disp : 'unknown';
if (subject) {
events.push({
event: EVENT.UPDATED,
subject,
data: {
node,
nodeContext,
action,
inputMethod,
sourceEvent,
isUndo,
isRedo,
previousDisplay
}
});
}
}
const pushEvents = (entities, event) => {
for (let i = 0; i < entities.length; i++) {
const {
node,
nodeContext
} = entities[i];
const subject = getNodeSubject(node);
if (subject) {
events.push({
event,
subject,
data: {
node,
nodeContext,
action,
inputMethod,
sourceEvent,
isUndo,
isRedo
}
});
}
}
};
pushEvents(removed, EVENT.DELETED);
pushEvents(inserted, EVENT.CREATED);
return events;
} catch (err) {
return events;
}
}