@atlaskit/editor-plugin-tasks-and-decisions
Version:
Tasks and decisions plugin for @atlaskit/editor-core
346 lines (336 loc) • 14.9 kB
JavaScript
import { uuid } from '@atlaskit/adf-schema';
import { SetAttrsStep } from '@atlaskit/adf-schema/steps';
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
import { createSelectionClickHandler, GapCursorSelection } from '@atlaskit/editor-common/selection';
import { getStepRange } from '@atlaskit/editor-common/utils';
import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';
import { DecisionItemNodeView } from '../nodeviews/DecisionItemNodeView';
import { taskView } from '../nodeviews/task-node-view';
import { focusTaskDecision, openRequestEditPopup, setProvider } from './actions';
import { focusCheckbox, focusCheckboxAndUpdateSelection, getTaskItemDataAtPos, getTaskItemDataToFocus, isInsideTask, removeCheckboxFocus } from './helpers';
import { stateKey } from './plugin-key';
import { taskItemOnChange } from './taskItemOnChange';
import { applyTaskListNormalisationFixes } from './transforms';
import { ACTIONS } from './types';
import { tempTransformSliceToRemoveBlockTaskItem } from './utils/paste';
function nodesBetweenChanged(tr, f, startPos) {
const stepRange = getStepRange(tr);
if (!stepRange) {
return;
}
tr.doc.nodesBetween(stepRange.from, stepRange.to, f, startPos);
}
// eslint-disable-next-line jsdoc/require-jsdoc
export function createPlugin(portalProviderAPI, eventDispatcher, dispatch, api, getIntl, useLongPressSelection = false, hasEditPermission, hasRequestedEditPermission, requestToEditContent, taskPlaceholder) {
return new SafePlugin({
props: {
nodeViews: {
taskItem: taskView(api, getIntl(), taskPlaceholder),
decisionItem: node => {
return new DecisionItemNodeView(node, getIntl());
},
...(expValEquals('platform_editor_blocktaskitem_node_tenantid', 'isEnabled', true) ? {
blockTaskItem: taskView(api, getIntl(), taskPlaceholder)
} : {})
},
decorations(state) {
const pluginState = stateKey.getState(state);
if (pluginState !== null && pluginState !== void 0 && pluginState.decorations) {
return pluginState.decorations;
}
return DecorationSet.empty;
},
handleTextInput(view, from, to, text) {
// When a decision item is selected and the user starts typing, the entire node
// should be replaced with what was just typed. This custom text input handler
// is needed to implement that behaviour.
const {
state,
dispatch
} = view;
const {
tr
} = state;
if (state.selection instanceof NodeSelection && state.selection.node.type === view.state.schema.nodes.decisionItem) {
state.selection.replace(tr);
tr.insertText(text);
if (dispatch) {
dispatch(tr);
}
return true;
}
return false;
},
handleClickOn: createSelectionClickHandler(['decisionItem', 'taskItem'], target => target.hasAttribute('data-decision-wrapper') || target.getAttribute('aria-label') === 'Decision', {
useLongPressSelection
}),
handleDOMEvents: {
// When the page is lazy loaded and task item is not yet available this allows
// our toDOM implementation to toggle the node state
change: taskItemOnChange
},
handleKeyDown: (view, event) => {
const {
state
} = view;
const {
selection,
schema
} = state;
const {
$from,
$to
} = selection;
const parentOffset = $from.parentOffset;
const isInTaskItem = expValEquals('platform_editor_blocktaskitem_patch_1', 'isEnabled', true) ? isInsideTask(state) : $from.node().type === schema.nodes.taskItem;
const focusedTaskItemLocalId = stateKey.getState(state).focusedTaskItemLocalId;
const currentTaskItemData = getTaskItemDataAtPos(view);
const currentTaskItemFocused = focusedTaskItemLocalId === (currentTaskItemData === null || currentTaskItemData === void 0 ? void 0 : currentTaskItemData.localId);
// if task item checkbox not focused and arrow key is not pressed
// then we don't want to handle event.
if (!['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'].includes(event.key)) {
return false;
}
// We want to handle arrow up, down and left key only
// when selection is inside task item and no text is selected.
if (['ArrowUp', 'ArrowDown', 'ArrowLeft'].includes(event.key) && (!isInTaskItem || $from.pos !== $to.pos)) {
return false;
}
// Arrow keys are pressed and shift, ctrl or meta is pressed as well.
// along with arrow keys and task item checkbox is focused
// then first move focus to view and proceed with default event handling.
if (event.shiftKey || event.ctrlKey || event.metaKey) {
currentTaskItemFocused && removeCheckboxFocus(view);
return false;
}
// task item checkbox is already focused
if (focusedTaskItemLocalId) {
if (event.key === 'ArrowLeft') {
// Move focus to view and proceed with default keyboard handler.
// Which will move cursor to previous position.
removeCheckboxFocus(view);
return false;
}
if (event.key === 'ArrowRight') {
// Move focus to view and DON'T proceed with default handler.
// We have assumed that selection is already before first character of task item.
removeCheckboxFocus(view);
return true;
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const taskItemData = getTaskItemDataToFocus(view, event.key === 'ArrowUp' ? 'previous' : 'next');
if (taskItemData) {
focusCheckboxAndUpdateSelection(view, taskItemData);
return true;
} else {
// If any how checkbox input not found, then move focus to view
// and proceed with default keyboard handler.
removeCheckboxFocus(view);
return false;
}
}
}
// If left arrow key is pressed and cursor is at first position in task-item
// then focus checkbox and DON'T proceed with default keyboard handler
if (event.key === 'ArrowLeft' && parentOffset === 0) {
// here we are not using focusCheckboxAndUpdateSelection() method
// because it is working incorretly when we are placing is inside the nested items
focusCheckbox(view, currentTaskItemData);
return true;
}
if (event.key === 'ArrowRight') {
var _$from$nodeAfter;
// If gap cursor is just before task list then focus first task item in list.
if (selection instanceof GapCursorSelection && selection.side === 'left' && ((_$from$nodeAfter = $from.nodeAfter) === null || _$from$nodeAfter === void 0 ? void 0 : _$from$nodeAfter.type) === schema.nodes.taskList) {
const taskList = $from.nodeAfter;
const firstTaskItemNode = taskList.child(0);
const taskItemPos = $from.pos + 1;
focusCheckboxAndUpdateSelection(view, {
pos: taskItemPos,
localId: firstTaskItemNode.attrs.localId
});
return true;
}
// if cursor is at then end of task item text then focus next task item checkbox
else if (isInTaskItem && $from.node().content.size === parentOffset) {
const nextTaskItemData = getTaskItemDataToFocus(view, 'next');
if (nextTaskItemData) {
focusCheckboxAndUpdateSelection(view, nextTaskItemData);
return true;
}
} else {
return false;
}
}
},
transformPasted: (slice, view) => {
var _view$state, _view$state$schema, _view$state$schema$no;
if (Boolean(view === null || view === void 0 ? void 0 : (_view$state = view.state) === null || _view$state === void 0 ? void 0 : (_view$state$schema = _view$state.schema) === null || _view$state$schema === void 0 ? void 0 : (_view$state$schema$no = _view$state$schema.nodes) === null || _view$state$schema$no === void 0 ? void 0 : _view$state$schema$no.blockTaskItem)) {
slice = tempTransformSliceToRemoveBlockTaskItem(slice, view);
}
return slice;
}
},
state: {
init() {
return {
insideTaskDecisionItem: false,
hasEditPermission,
hasRequestedEditPermission,
requestToEditContent,
focusedTaskItemLocalId: null,
taskDecisionProvider: undefined,
decorations: DecorationSet.empty
};
},
apply(tr, pluginState) {
const metaData = tr.getMeta(stateKey);
const {
action,
data
} = metaData !== null && metaData !== void 0 ? metaData : {
action: null,
data: null
};
let newPluginState = pluginState;
// Actions
switch (action) {
case ACTIONS.FOCUS_BY_LOCALID:
newPluginState = focusTaskDecision(newPluginState, {
action: ACTIONS.FOCUS_BY_LOCALID,
data
});
break;
case ACTIONS.SET_PROVIDER:
newPluginState = setProvider(newPluginState, {
action: ACTIONS.SET_PROVIDER,
data
});
break;
case ACTIONS.OPEN_REQUEST_TO_EDIT_POPUP:
newPluginState = openRequestEditPopup(newPluginState, {
action: ACTIONS.OPEN_REQUEST_TO_EDIT_POPUP,
data
});
break;
}
// Commands
if (metaData && 'hasEditPermission' in metaData) {
newPluginState = {
...newPluginState,
hasEditPermission: metaData.hasEditPermission
};
}
if (metaData && 'hasRequestedEditPermission' in metaData) {
newPluginState = {
...newPluginState,
hasRequestedEditPermission: metaData.hasRequestedEditPermission
};
}
const taskItemInfo = tr.getMeta('taskItemInfo');
let decorations = DecorationSet.empty;
if (taskItemInfo) {
decorations = DecorationSet.create(tr.doc, [Decoration.inline(taskItemInfo.from, taskItemInfo.to, {}, {
dataTaskNodeCheckState: taskItemInfo.checkState
})]);
} else {
var _newPluginState$decor;
decorations = (_newPluginState$decor = newPluginState.decorations) === null || _newPluginState$decor === void 0 ? void 0 : _newPluginState$decor.map(tr.mapping, tr.doc);
}
const newState = {
...newPluginState,
decorations
};
dispatch(stateKey, newState);
return newState;
}
},
key: stateKey,
/*
* After each transaction, we search through the document for any decisionList/Item & taskList/Item nodes
* that do not have the localId attribute set and generate a random UUID to use. This is to replace a previous
* Prosemirror capability where node attributes could be generated dynamically.
* See https://discuss.prosemirror.net/t/release-0-23-0-possibly-to-be-1-0-0/959/17 for a discussion of this approach.
*
* Note: we currently do not handle the edge case where two nodes may have the same localId
*/
appendTransaction: (transactions, _oldState, newState) => {
// Normalise taskList structure first — runs on its own transaction (without addToHistory: false)
// so it is included in history and correctly undone with the triggering operation.
if (expValEqualsNoExposure('platform_editor_flexible_list_schema', 'isEnabled', true) && transactions.some(t => t.docChanged)) {
const normTr = applyTaskListNormalisationFixes({
tr: newState.tr,
transactions,
doc: newState.doc,
schema: newState.schema
});
if (normTr.docChanged) {
// Return the normalisation transaction — the next appendTransaction call will
// assign any missing localIds to newly inserted nodes via the localId path below.
return normTr;
}
}
// Assign unique localIds to any new nodes that don't have one.
// Runs with addToHistory: false so localId assignment is not part of the undo history.
const tr = newState.tr;
let modified = false;
transactions.forEach(transaction => {
if (!transaction.docChanged) {
return;
}
// Adds a unique id to a node
nodesBetweenChanged(transaction, (node, pos) => {
const {
decisionList,
decisionItem,
taskList,
taskItem,
blockTaskItem
} = newState.schema.nodes;
if (!!node.type && (node.type === decisionList || node.type === decisionItem || node.type === taskList || node.type === taskItem || blockTaskItem && node.type === blockTaskItem)) {
const {
localId,
...rest
} = node.attrs;
if (localId === undefined || localId === null || localId === '') {
tr.step(new SetAttrsStep(pos, {
localId: uuid.generate(),
...rest
}));
modified = true;
}
}
});
});
if (modified) {
return tr.setMeta('addToHistory', false);
}
return;
},
view: () => {
return {
update: (view, prevState) => {
const pluginState = stateKey.getState(view.state);
const prevPluginState = stateKey.getState(prevState);
if (pluginState.focusedTaskItemLocalId === prevPluginState.focusedTaskItemLocalId) {
return;
}
const taskItem = getTaskItemDataAtPos(view);
if (!taskItem) {
return;
}
if (pluginState.focusedTaskItemLocalId === taskItem.localId) {
const taskElement = view.nodeDOM(taskItem.pos);
if (taskElement instanceof HTMLElement) {
var _taskElement$querySel;
taskElement === null || taskElement === void 0 ? void 0 : (_taskElement$querySel = taskElement.querySelector('input')) === null || _taskElement$querySel === void 0 ? void 0 : _taskElement$querySel.focus();
}
}
}
};
}
});
}