UNPKG

@atlaskit/editor-plugin-card

Version:

Card plugin for @atlaskit/editor-core

381 lines (367 loc) 12.7 kB
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; } }