UNPKG

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

Version:

Tasks and decisions plugin for @atlaskit/editor-core

556 lines (531 loc) 17.6 kB
import { GapCursorSelection } from '@atlaskit/editor-common/selection'; import { findFarthestParentNode, isListNode } 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 { findParentNodeClosestToPos, findParentNodeOfTypeClosestToPos, hasParentNodeOfType } from '@atlaskit/editor-prosemirror/utils'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { stateKey } from './plugin-key'; import { ACTIONS } from './types'; import { findBlockTaskItem } from './utils'; export const isInsideTaskOrDecisionItem = state => { const { decisionItem, taskItem, blockTaskItem } = state.schema.nodes; if (blockTaskItem) { return Boolean(findParentNodeOfTypeClosestToPos(state.selection.$from, [decisionItem, taskItem, blockTaskItem])); } return hasParentNodeOfType([decisionItem, taskItem, blockTaskItem])(state.selection); }; export const isActionOrDecisionList = node => { const { taskList, decisionList } = node.type.schema.nodes; return [taskList, decisionList].indexOf(node.type) > -1; }; export const isActionOrDecisionItem = node => { const { taskItem, decisionItem, blockTaskItem } = node.type.schema.nodes; return [taskItem, decisionItem, blockTaskItem].indexOf(node.type) > -1; }; export const isInsideTask = state => { const { taskItem, blockTaskItem } = state.schema.nodes; return hasParentNodeOfType([taskItem, blockTaskItem])(state.selection); }; export const isInsideDecision = state => { const { decisionItem } = state.schema.nodes; return hasParentNodeOfType([decisionItem])(state.selection); }; export const isTable = node => { if (!node) { return false; } const { table, tableHeader, tableCell, tableRow } = node.type.schema.nodes; return [table, tableHeader, tableCell, tableRow].includes(node.type); }; /** * Creates a NodeRange around the given taskItem and the following * ("nested") taskList, if one exists. */ export const getBlockRange = ({ $from, $to }) => { const { taskList, taskItem, blockTaskItem, paragraph } = $from.doc.type.schema.nodes; if (blockTaskItem) { const result = findBlockTaskItem($from); if (result) { var _$prevNode$nodeBefore; const { hasParagraph } = result; const blockTaskItemDepth = hasParagraph ? $from.depth - 1 : $from.depth; let blockRangeDepth = blockTaskItemDepth - 1; // Calculate start position of the block range let startPos = $from.start(blockTaskItemDepth) - 1; // Calculate end position and get node after the current selection const endPos = $to.end(); let $after = $to.doc.resolve(endPos + 1); let afterNode = $after.nodeAfter; const lastNode = $to.node($to.depth); let endRangePos = $to.start() + lastNode.nodeSize; // Make adjustments for paragraph nodes if (lastNode.type === paragraph) { $after = $to.doc.resolve(endPos + 2); afterNode = $after.nodeAfter; } else { blockRangeDepth--; endRangePos--; } // Extend range if there's a sibling taskList if (afterNode && afterNode.type === taskList && $after.depth === blockTaskItemDepth - 1) { endRangePos += afterNode.nodeSize; } // Check if preceded by another taskItem/blockTaskItem const $prevNode = $from.doc.resolve(startPos - 1); const prevNodeSize = ((_$prevNode$nodeBefore = $prevNode.nodeBefore) === null || _$prevNode$nodeBefore === void 0 ? void 0 : _$prevNode$nodeBefore.nodeSize) || 0; const $prevNodeParent = $from.doc.resolve($prevNode.pos - prevNodeSize - 1); const prevNodeParent = $prevNodeParent.nodeAfter; if (prevNodeParent && [blockTaskItem, taskItem].includes(prevNodeParent.type)) { blockRangeDepth = blockTaskItemDepth - 1; endRangePos -= 2; startPos += 1; } // Create and return the NodeRange return new NodeRange($to.doc.resolve(startPos), $to.doc.resolve(endRangePos), blockRangeDepth); } } let end = $to.end(); const $after = $to.doc.resolve(end + 1); const after = $after.nodeAfter; // ensure the node after is actually just a sibling // $to will be inside the text, so subtract one to get the taskItem it contains in if (after && after.type === taskList && $after.depth === $to.depth - 1) { // it was! include it in our blockRange end += after.nodeSize; } return $from.blockRange($to.doc.resolve(end)); }; /** * Calculates the current indent level of the selection within a task list in the ProseMirror document. * * The indent level is determined by finding the depth difference between the selection and the furthest parent * node of type `taskList`. If the selection is inside a `blockTaskItem`, the calculation is adjusted to avoid * counting nested block items as additional indent levels. * * @param selection - The current ProseMirror selection. * @returns The indent level as a number, or `null` if the selection is not inside a task list. * @example * ```typescript * const indentLevel = getCurrentIndentLevel(editorState.selection); * ``` */ export const getCurrentIndentLevel = selection => { const { $from } = selection; const { taskList, blockTaskItem } = $from.doc.type.schema.nodes; const furthestParent = findFarthestParentNode(node => node.type === taskList)($from); if (!furthestParent) { return null; } if (hasParentNodeOfType([blockTaskItem])(selection)) { const blockTaskItemNode = findFarthestParentNode(node => node.type === blockTaskItem)($from); if (blockTaskItemNode) { /** * If we are inside a blockTaskItem, calculate the indent level from the * blockTaskItemNode instead of the selection, in case the selection is * nested inside a blockTaskItem. */ return blockTaskItemNode.depth - furthestParent.depth; } } return $from.depth - furthestParent.depth; }; /** * Finds the index of the current task item in relation to the closest taskList */ export const getTaskItemIndex = state => { const $pos = state.selection.$from; const isTaskList = node => (node === null || node === void 0 ? void 0 : node.type.name) === 'taskList'; const itemAtPos = findParentNodeClosestToPos($pos, isTaskList); return $pos.index(itemAtPos ? itemAtPos.depth : undefined); }; /** * Walk outwards from a position until we encounter the (inside) start of * the next node, or reach the end of the document. * * @param $startPos Position to start walking from. */ export const walkOut = $startPos => { let $pos = $startPos; // invariant 1: don't walk past the end of the document // invariant 2: we haven't walked to the start of *any* node // parentOffset includes textOffset. while ($pos.pos < $pos.doc.nodeSize - 2 && $pos.parentOffset > 0) { $pos = $pos.doc.resolve($pos.pos + 1); } return $pos; }; /** * Finds the height of a tree-like structure, given any position inside it. * * Traverses from the top of the tree to all leaf nodes, and returns the length * of the longest path. * * This means you can use it with things like taskList, which * do not nest themselves inside taskItems but rather as adjacent children. * * @param $pos Any position inside the tree. * @param types The node types to consider traversable */ export const subtreeHeight = ($from, $to, types) => { const root = findFarthestParentNode(node => types.indexOf(node.type) > -1)($from); if (!root) { return -1; } // get the height between the root and the current position const distToParent = $from.depth - root.depth; // include any following taskList since nested lists appear // as siblings // // this is unlike regular bullet lists where the orderedList // appears as descendent of listItem const blockRange = getBlockRange({ $from, $to }); if (!blockRange) { return -1; } // and get the max height from the current position to the // deepest leaf node let maxChildDepth = $from.depth; $from.doc.nodesBetween(blockRange.start, blockRange.end, (descendent, relPos, _parent) => { maxChildDepth = Math.max($from.doc.resolve(relPos).depth, maxChildDepth); // keep descending down the tree if we can if (types.indexOf(descendent.type) > -1) { return true; } }); return distToParent + (maxChildDepth - $from.depth); }; /** * Determines if the current selection is inside an empty taskItem, decisionItem, or blockTaskItem. * * @param state - The current EditorState. * @returns `true` if the taskItem, decisionItem, or blockTaskItem is empty; otherwise, `false`. */ export const isEmptyTaskDecision = state => { const { selection, schema } = state; const { paragraph, blockTaskItem, decisionItem, taskItem } = schema.nodes; const { $from } = selection; const node = $from.node($from.depth); const isEmptyTaskOrDecisionItem = node && (node.type === taskItem || node.type === decisionItem) && node.content.size === 0; // Block task items must contain a single empty paragraph to be considered empty const isInEmptyBlockTaskItem = // If in an empty paragraph that's not at the doc level node.content.size === 0 && node.type === paragraph && $from.depth > 0 && // and it's parent is a blockTaskItem with only this paragraph inside it $from.node($from.depth - 1).type === blockTaskItem && $from.node($from.depth - 1).childCount === 1; return isEmptyTaskOrDecisionItem || isInEmptyBlockTaskItem; }; /** * Lifts a taskItem and any directly following taskList * (taskItem and its "nested children") out one level. * * @param tr Transaction to base steps on * @param $from Start of range you want to lift * @param $to End of range you want to lift (can be same as `$from`) */ export const liftBlock = (tr, $from, $to) => { const blockRange = getBlockRange({ $from, $to }); if (!blockRange) { return null; } // ensure we can actually lift const target = liftTarget(blockRange); if (typeof target !== 'number') { return null; } return tr.lift(blockRange, target).scrollIntoView(); }; export function getTaskItemDataAtPos(view) { const { state } = view; const { selection, schema } = state; const { $from } = selection; if (expValEquals('platform_editor_blocktaskitem_patch_1', 'isEnabled', true)) { const { taskItem, blockTaskItem } = schema.nodes; const maybeTask = findParentNodeOfTypeClosestToPos($from, [taskItem, blockTaskItem]); // current selection has to be inside taskitem if (maybeTask) { return { pos: maybeTask === null || maybeTask === void 0 ? void 0 : maybeTask.pos, localId: maybeTask === null || maybeTask === void 0 ? void 0 : maybeTask.node.attrs.localId }; } } else { const isInTaskItem = $from.node().type === schema.nodes.taskItem; // current selection has to be inside taskitem if (isInTaskItem) { const taskItemPos = $from.before(); return { pos: taskItemPos, localId: $from.node().attrs.localId }; } } } export function getAllTaskItemsDataInRootTaskList(view) { const { state } = view; const { schema } = state; const $fromPos = state.selection.$from; const isInTaskItem = expValEquals('platform_editor_blocktaskitem_patch_1', 'isEnabled', true) ? isInsideTask(state) : $fromPos.node().type === schema.nodes.taskItem; // if not inside task item then return undefined; if (!isInTaskItem) { return; } const { taskList, taskItem, blockTaskItem } = schema.nodes; const rootTaskListData = findFarthestParentNode(node => node.type === taskList)($fromPos); if (rootTaskListData) { const rootTaskList = rootTaskListData.node; const rootTaskListStartPos = rootTaskListData.start; const allTaskItems = []; rootTaskList.descendants((node, pos, parent, index) => { if (node.type === taskItem || expValEquals('platform_editor_blocktaskitem_patch_1', 'isEnabled', true) && blockTaskItem && node.type === blockTaskItem) { allTaskItems.push({ node, pos: pos + rootTaskListStartPos, index }); } }); return allTaskItems; } } export function getCurrentTaskItemIndex(view, allTaskItems) { var _findParentNodeOfType; const { state } = view; const { schema } = state; const { taskItem, blockTaskItem } = schema.nodes; const $fromPos = state.selection.$from; const allTaskItemNodes = allTaskItems.map(nodeData => nodeData.node); const currentTaskItem = expValEquals('platform_editor_blocktaskitem_patch_1', 'isEnabled', true) ? (_findParentNodeOfType = findParentNodeOfTypeClosestToPos($fromPos, [taskItem, blockTaskItem])) === null || _findParentNodeOfType === void 0 ? void 0 : _findParentNodeOfType.node : $fromPos.node($fromPos.depth); if (currentTaskItem) { const currentTaskItemIndex = allTaskItemNodes.indexOf(currentTaskItem); return currentTaskItemIndex; } else { return -1; } } export function getTaskItemDataToFocus(view, direction) { const allTaskItems = getAllTaskItemsDataInRootTaskList(view); // if not inside task item then allTaskItems will be undefined; if (!allTaskItems) { return; } const currentTaskItemIndex = getCurrentTaskItemIndex(view, allTaskItems); if (direction === 'next' ? currentTaskItemIndex === allTaskItems.length - 1 : currentTaskItemIndex === 0) { // checkbox of first or last task item is already focused based on direction. return; } const indexOfTaskItemToFocus = direction === 'next' ? currentTaskItemIndex + 1 : currentTaskItemIndex - 1; const taskItemToFocus = allTaskItems[indexOfTaskItemToFocus]; return { pos: taskItemToFocus.pos, localId: taskItemToFocus.node.attrs.localId }; } export function focusCheckbox(view, taskItemData) { const { state, dispatch } = view; const { tr } = state; if (taskItemData) { tr.setMeta(stateKey, { action: ACTIONS.FOCUS_BY_LOCALID, data: taskItemData.localId }); dispatch(tr); } } export function focusCheckboxAndUpdateSelection(view, taskItemData) { var _doc$resolve$nodeAfte, _doc$resolve$nodeAfte2; const { pos, localId } = taskItemData; const { state, dispatch } = view; const { doc } = state; const { schema } = state; const { extension } = schema.nodes; const tr = state.tr; // if there's an extension at this position, we're in a blockTaskItem, set a gapCursor if (expValEquals('platform_editor_blocktaskitem_patch_1', 'isEnabled', true) && extension && ((_doc$resolve$nodeAfte = doc.resolve(pos + 1).nodeAfter) === null || _doc$resolve$nodeAfte === void 0 ? void 0 : _doc$resolve$nodeAfte.type) === extension) { tr.setSelection(new GapCursorSelection(doc.resolve(pos + 1))); // if there's a textblock at this position, we're in a blockTaskItem, add an extra hop into the content } else if (expValEquals('platform_editor_blocktaskitem_patch_1', 'isEnabled', true) && (_doc$resolve$nodeAfte2 = doc.resolve(pos + 1).nodeAfter) !== null && _doc$resolve$nodeAfte2 !== void 0 && _doc$resolve$nodeAfte2.isTextblock) { tr.setSelection(new TextSelection(doc.resolve(pos + 2))); // else, this is an ordinary task item with inline content } else { tr.setSelection(new TextSelection(doc.resolve(pos + 1))); } tr.setMeta(stateKey, { action: ACTIONS.FOCUS_BY_LOCALID, data: localId }); dispatch(tr); } export function removeCheckboxFocus(view) { const { state, dispatch } = view; const { tr } = state; view.focus(); dispatch(tr.setMeta(stateKey, { action: ACTIONS.FOCUS_BY_LOCALID })); } export function openRequestEditPopupAt(view, pos) { const { state, dispatch } = view; const { tr } = state; dispatch(tr.setMeta(stateKey, { action: ACTIONS.OPEN_REQUEST_TO_EDIT_POPUP, data: pos })); } export function closeRequestEditPopupAt(view) { const { state, dispatch } = view; const { tr } = state; dispatch(tr.setMeta(stateKey, { action: ACTIONS.OPEN_REQUEST_TO_EDIT_POPUP, data: null })); } export function findFirstParentListNode($pos) { const currentNode = $pos.doc.nodeAt($pos.pos); let listNodePosition = null; if (isListNode(currentNode)) { listNodePosition = $pos.pos; } else { const result = findParentNodeClosestToPos($pos, isListNode); listNodePosition = result && result.pos; } if (listNodePosition == null) { return null; } const node = $pos.doc.nodeAt(listNodePosition); if (!node) { return null; } return { node, pos: listNodePosition }; } export const isInFirstTextblockOfBlockTaskItem = state => { const { $from } = state.selection; const { blockTaskItem } = state.schema.nodes; return $from.parent.isTextblock && $from.node($from.depth - 1).type === blockTaskItem && $from.index($from.depth - 1) === 0; }; export const isInLastTextblockOfBlockTaskItem = state => { const { $from } = state.selection; const { blockTaskItem } = state.schema.nodes; const parentNode = $from.node($from.depth - 1); return $from.parent.isTextblock && parentNode.type === blockTaskItem && $from.index($from.depth - 1) === parentNode.childCount - 1; };