UNPKG

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

Version:

Tasks and decisions plugin for @atlaskit/editor-core

494 lines (474 loc) 15.2 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 { 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; }; /** * 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; 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 }; } } export function getAllTaskItemsDataInRootTaskList(view) { const { state } = view; const { schema } = state; const $fromPos = state.selection.$from; // if not inside task item then return undefined; if (!isInsideTask(state)) { 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 || 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 = (_findParentNodeOfType = findParentNodeOfTypeClosestToPos($fromPos, [taskItem, blockTaskItem])) === null || _findParentNodeOfType === void 0 ? void 0 : _findParentNodeOfType.node; 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 (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 ((_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; };