UNPKG

@atlaskit/editor-plugin-tasks-and-decisions

Version:

Tasks and decisions plugin for @atlaskit/editor-core

289 lines (281 loc) 9.86 kB
import { uuid } from '@atlaskit/adf-schema'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD, USER_CONTEXT } from '@atlaskit/editor-common/analytics'; import { GapCursorSelection } from '@atlaskit/editor-common/selection'; import { autoJoinTr } from '@atlaskit/editor-common/utils'; import { NodeRange } from '@atlaskit/editor-prosemirror/model'; import { TextSelection } from '@atlaskit/editor-prosemirror/state'; import { liftTarget } from '@atlaskit/editor-prosemirror/transform'; import { findParentNodeOfType, hasParentNodeOfType, replaceParentNodeOfType, safeInsert, setTextSelection } from '@atlaskit/editor-prosemirror/utils'; import { fg } from '@atlaskit/platform-feature-flags'; import { stateKey } from './plugin-key'; import { ACTIONS } from './types'; const getContextData = (contextProvider = {}) => { const { objectId, containerId } = contextProvider; const userContext = objectId ? USER_CONTEXT.EDIT : USER_CONTEXT.NEW; return { objectId, containerId, userContext }; }; const generateAnalyticsPayload = (listType, contextData, inputMethod, itemLocalId, listLocalId, itemIdx, listSize) => { let containerId; let objectId; let userContext; if (contextData) { ({ containerId, objectId, userContext } = contextData); } const resolvedInputMethod = fg('platform_editor_element_browser_analytic') ? inputMethod : INPUT_METHOD.QUICK_INSERT; return { action: ACTION.INSERTED, actionSubject: ACTION_SUBJECT.DOCUMENT, actionSubjectId: listType === 'taskList' ? ACTION_SUBJECT_ID.ACTION : ACTION_SUBJECT_ID.DECISION, eventType: EVENT_TYPE.TRACK, attributes: { inputMethod: resolvedInputMethod, containerAri: containerId, objectAri: objectId, userContext, localId: itemLocalId, listLocalId, position: itemIdx, listSize } }; }; export const getListTypes = (listType, schema) => { const { decisionList, decisionItem, taskList, taskItem } = schema.nodes; if (listType === 'taskList') { return { list: taskList, item: taskItem }; } return { list: decisionList, item: decisionItem }; }; export const insertTaskDecisionAction = (editorAnalyticsAPI, getContextIdentifierProvider) => (state, listType, inputMethod = INPUT_METHOD.TOOLBAR, addItem, listLocalId, itemLocalId, itemAttrs) => { const { schema } = state; const addAndCreateList = ({ tr, list, item, listLocalId, itemLocalId }) => createListAtSelection(tr, list, item, schema, state, listLocalId, itemLocalId, itemAttrs); const addToList = ({ state, tr, item, itemLocalId }) => { const { $to } = state.selection; const endPos = $to.end($to.depth); const newItemParagraphPos = endPos + 2; return tr.split(endPos, 1, [{ type: item, attrs: { localId: itemLocalId } }]).setSelection(new TextSelection(tr.doc.resolve(newItemParagraphPos))); }; const addAndCreateListFn = addItem !== null && addItem !== void 0 ? addItem : addAndCreateList; const tr = insertTaskDecisionWithAnalytics(editorAnalyticsAPI, getContextIdentifierProvider)(state, listType, inputMethod, addAndCreateListFn, addToList, listLocalId, itemLocalId, itemAttrs); if (!tr) { return state.tr; } autoJoinTr(tr, ['taskList', 'decisionList']); return tr; }; export const insertTaskDecisionCommand = (editorAnalyticsAPI, getContextIdentifierProvider) => (listType, inputMethod = INPUT_METHOD.TOOLBAR, addItem, listLocalId, itemLocalId) => (state, dispatch) => { const tr = insertTaskDecisionAction(editorAnalyticsAPI, getContextIdentifierProvider)(state, listType, inputMethod, addItem, listLocalId, itemLocalId); if (dispatch) { dispatch(tr); } return true; }; export const insertTaskDecisionWithAnalytics = (editorAnalyticsAPI, getContextIdentifierProvider) => (state, listType, inputMethod, addAndCreateList, addToList, listLocalId, itemLocalId, itemAttrs) => { const { schema } = state; const { list, item } = getListTypes(listType, schema); const { tr } = state; const { $to } = state.selection; const listNode = findParentNodeOfType(list)(state.selection); const contextIdentifierProvider = getContextIdentifierProvider(); const contextData = getContextData(contextIdentifierProvider); let insertTrCreator; let itemIdx; let listSize; if (!listNode) { // Not a list - convert to one. itemIdx = 0; listSize = 1; insertTrCreator = addAndCreateList; } else if ($to.node().textContent.length >= 0) { listSize = listNode.node.childCount + 1; listLocalId = listLocalId || listNode.node.attrs.localId; const listItemNode = findParentNodeOfType(item)(state.selection); // finds current item in list itemIdx = listItemNode ? state.doc.resolve(listItemNode.pos).index() + 1 : 0; insertTrCreator = addToList ? addToList : addAndCreateList; } listLocalId = listLocalId || uuid.generate(); itemLocalId = itemLocalId || uuid.generate(); if (insertTrCreator) { const insertTr = insertTrCreator({ state, tr, list, item, listLocalId, itemLocalId, itemAttrs }); if (insertTr) { editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent(generateAnalyticsPayload(listType, contextData, inputMethod, itemLocalId, listLocalId, itemIdx || 0, listSize || 0))(insertTr); } return insertTr; } return null; }; export const isSupportedSourceNode = (schema, selection) => { const { paragraph, blockquote, decisionList, taskList, bulletList, numberedList } = schema.nodes; return hasParentNodeOfType([blockquote, paragraph, decisionList, taskList])(selection) && !hasParentNodeOfType([bulletList, numberedList])(selection); }; export const changeInDepth = (before, after) => after.depth - before.depth; export const createListAtSelection = (tr, list, item, schema, state, listLocalId = uuid.generate(), itemLocalId = uuid.generate(), itemAttrs) => { const { selection } = state; const { $from, $to } = selection; if ($from.parent !== $to.parent) { // ignore selections across multiple nodes return null; } const { paragraph, blockquote, decisionList, taskList, decisionItem, taskItem, mediaGroup } = schema.nodes; if ($from.parent.type === mediaGroup) { return null; } const emptyList = list.create({ localId: listLocalId }, [item.create({ localId: itemLocalId, ...itemAttrs })]); // we don't take the content of a block node next to the gap cursor and always create an empty task if (selection instanceof GapCursorSelection) { return safeInsert(emptyList)(tr); } // try to replace when selection is in nodes which support it if (isSupportedSourceNode(schema, selection)) { const { type: nodeType, childCount } = selection.$from.node(); const newListNode = list.create({ localId: uuid.generate() }, [item.create({ localId: uuid.generate() }, $from.node($from.depth).content)]); const hasBlockquoteParent = findParentNodeOfType(blockquote)(selection); if (hasBlockquoteParent) { const liftedDepth = $from.depth - 1; const range = new NodeRange($from, $to, liftedDepth); tr.lift(range, liftTarget(range)); } const listParent = findParentNodeOfType(taskList)(selection) || findParentNodeOfType(decisionList)(selection); const listItem = findParentNodeOfType(taskItem)(selection) || findParentNodeOfType(decisionItem)(selection); // For a selection inside a task/decision list, we can't just simply replace the // node type as it will mess up lists with > 1 item if (listParent && listItem) { let start; let end; let selectionPos = selection.from; // if selection is in first item in list, we need to delete extra so that // this list isn't split if (listParent.node.firstChild === listItem.node) { start = listParent.start - 1; end = listItem.start + listItem.node.nodeSize; if (listParent.node.childCount === 1) { end = listParent.start + listParent.node.nodeSize - 1; } } else { start = listItem.start - 1; end = listItem.start + listItem.node.nodeSize; selectionPos += 2; // as we have added the new list node } tr.replaceWith(start, end, newListNode); tr = setTextSelection(selectionPos)(tr); return tr; } // For a selection inside one of these node types we can just convert the node type const nodeTypesToReplace = [blockquote]; if (nodeType === paragraph && childCount > 0 || hasBlockquoteParent) { // Only convert paragraphs containing content. // Empty paragraphs use the default flow. // This distinction ensures the text selection remains in the correct location. // We also want to replace the paragraph type when we are inside a blockQuote // to avoid inserting an extra taskList whilst keeping the paragraph nodeTypesToReplace.push(paragraph); } let newTr = tr; newTr = replaceParentNodeOfType(nodeTypesToReplace, newListNode)(tr); // Adjust depth for new selection, if it has changed (e.g. paragraph to list (ol > li)) const depthAdjustment = changeInDepth($to, newTr.selection.$to); tr = tr.setSelection(new TextSelection(tr.doc.resolve($to.pos + depthAdjustment))); // replacing successful if (newTr !== tr) { return tr; } } return safeInsert(emptyList)(tr); }; export const setProvider = provider => tr => { return tr.setMeta(stateKey, { action: ACTIONS.SET_PROVIDER, data: provider }); };