@atlaskit/editor-plugin-tasks-and-decisions
Version:
Tasks and decisions plugin for @atlaskit/editor-core
202 lines (192 loc) • 7.43 kB
JavaScript
import { uuid } from '@atlaskit/adf-schema';
import { flattenList as flattenListBase } from '@atlaskit/editor-common/lists';
/**
* Flattens a taskList tree into an array of task items with computed depths.
* Only selected items have their depth adjusted by indentDelta.
*
* Delegates to the shared `flattenList` with task-list-specific callbacks.
*/
export function flattenTaskList(options) {
const {
taskList,
taskItem,
blockTaskItem
} = options.doc.type.schema.nodes;
return flattenListBase(options, {
isContentNode: (node, parent) => {
const isTaskItemType = node.type === taskItem || blockTaskItem != null && node.type === blockTaskItem;
return isTaskItemType && parent != null && parent.type === taskList;
},
getSelectionBounds: (node, pos) => ({
start: pos,
end: pos + node.nodeSize
}),
getDepth: (resolvedDepth, rootDepth) => resolvedDepth - rootDepth - 1
});
}
// Each stack entry collects children for a taskList at a given depth.
// The root entry (depth -1) is special: its children become the content
// of the outermost taskList directly (not wrapped in another taskList).
// Entries at depth >= 0 each produce a nested taskList when popped.
/**
* Rebuilds a taskList tree from a flattened array of task items.
* Uses a stack-based approach to create proper nesting.
*
* Preserves original taskList attributes (e.g. localId) for wrappers
* of unselected items. Only generates fresh UUIDs for newly-created
* nesting levels (i.e. when items were moved via indent/outdent).
*
* Given items with depths [A:0, B:1, C:2, D:1, E:0], produces:
*
* taskList
* taskItem 'A'
* taskList
* taskItem 'B'
* taskList
* taskItem 'C'
* taskItem 'D'
* taskItem 'E'
*
* Also computes `contentStartOffsets`: for each item (by index),
* the offset within the returned fragment where the item's content
* begins. This is used for accurate selection restoration.
*/
export function rebuildTaskList(items, schema) {
var _stack$0$listAttrs;
const {
taskList
} = schema.nodes;
const contentStartOffsets = new Array(items.length);
if (items.length === 0) {
return null;
}
// Start with the root level (depth -1 represents the root taskList wrapper)
const stack = [{
depth: -1,
children: [],
hasSelectedItems: false,
listAttrs: items[0].parentListAttrs,
sourceParentAttrs: items[0].parentListAttrs
}];
for (const item of items) {
const targetDepth = item.depth;
// Pop stack entries strictly deeper than target, wrapping them into taskLists
while (stack.length > 1 && stack[stack.length - 1].depth > targetDepth) {
var _popped$listAttrs;
const popped = stack.pop();
if (!popped) {
break;
}
const attrs = (_popped$listAttrs = popped.listAttrs) !== null && _popped$listAttrs !== void 0 ? _popped$listAttrs : {
localId: uuid.generate()
};
const wrappedList = taskList.create(attrs, popped.children);
stack[stack.length - 1].children.push(wrappedList);
}
// If the stack top is at the same depth as the target, decide whether
// to merge into the existing entry or close it and start a new one.
// Unselected items from different original parent taskLists get
// separate wrappers (preserving original nesting). Selected (moved)
// items always merge with whatever is already at the target depth,
// since they're being placed into a new structural context.
// However, if the current entry already contains selected (moved) items,
// we do NOT split — the unselected sibling should join the same wrapper
// because the selected item established the new structural context.
if (stack.length > 1 && stack[stack.length - 1].depth === targetDepth && !item.isSelected) {
const top = stack[stack.length - 1];
if (top.sourceParentAttrs !== item.parentListAttrs && !top.hasSelectedItems) {
var _popped$listAttrs2;
const popped = stack.pop();
if (!popped) {
break;
}
const attrs = (_popped$listAttrs2 = popped.listAttrs) !== null && _popped$listAttrs2 !== void 0 ? _popped$listAttrs2 : {
localId: uuid.generate()
};
const wrappedList = taskList.create(attrs, popped.children);
stack[stack.length - 1].children.push(wrappedList);
}
}
// Push new stack entries to reach the target depth.
// Skip intermediate entries at depth 0 — depth-0 items belong
// directly in the root entry (depth -1), so we only need
// intermediates for depths > 1.
while (stack[stack.length - 1].depth < targetDepth - 1) {
const nextDepth = Math.max(stack[stack.length - 1].depth + 1, 1);
stack.push({
depth: nextDepth,
children: [],
hasSelectedItems: false,
listAttrs: item.isSelected ? null : item.parentListAttrs,
sourceParentAttrs: item.parentListAttrs
});
}
// Add the item at the target depth
if (targetDepth === 0) {
// Depth 0 items go directly into the root taskList
stack[0].children.push(item.node);
if (item.isSelected) {
stack[0].hasSelectedItems = true;
}
} else {
// Ensure there's a stack entry at depth targetDepth to hold this item
if (stack[stack.length - 1].depth < targetDepth) {
stack.push({
depth: targetDepth,
children: [],
hasSelectedItems: false,
listAttrs: item.isSelected ? null : item.parentListAttrs,
sourceParentAttrs: item.parentListAttrs
});
}
stack[stack.length - 1].children.push(item.node);
if (item.isSelected) {
stack[stack.length - 1].hasSelectedItems = true;
}
}
}
// Close remaining stack entries
while (stack.length > 1) {
var _popped$listAttrs3;
const popped = stack.pop();
if (!popped) {
break;
}
const attrs = (_popped$listAttrs3 = popped.listAttrs) !== null && _popped$listAttrs3 !== void 0 ? _popped$listAttrs3 : {
localId: uuid.generate()
};
const wrappedList = taskList.create(attrs, popped.children);
stack[stack.length - 1].children.push(wrappedList);
}
// The root entry's children form the root taskList.
// Preserve the root's original attrs when available.
const rootChildren = stack[0].children;
const rootAttrs = (_stack$0$listAttrs = stack[0].listAttrs) !== null && _stack$0$listAttrs !== void 0 ? _stack$0$listAttrs : {
localId: uuid.generate()
};
const rootList = taskList.create(rootAttrs, rootChildren);
// Compute contentStartOffsets by walking the rebuilt tree.
// Each taskItem's content starts at (pos_within_root + 1) for the taskItem opening tag.
// We add +1 for the root taskList's opening tag.
const isTaskItemType = node => {
const {
taskItem,
blockTaskItem
} = schema.nodes;
return node.type === taskItem || blockTaskItem != null && node.type === blockTaskItem;
};
let itemIdx = 0;
rootList.descendants((node, pos) => {
if (isTaskItemType(node) && itemIdx < items.length) {
// pos is relative to rootList content start;
// +1 for rootList's opening tag, +1 for taskItem's opening tag
contentStartOffsets[itemIdx] = 1 + pos + 1;
itemIdx++;
}
return true;
});
return {
node: rootList,
contentStartOffsets
};
}