@atlaskit/editor-plugin-tasks-and-decisions
Version:
Tasks and decisions plugin for @atlaskit/editor-core
289 lines (281 loc) • 9.86 kB
JavaScript
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
});
};