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