@atlaskit/editor-plugin-annotation
Version:
Annotation plugin for @atlaskit/editor-core
255 lines (243 loc) • 10.5 kB
JavaScript
import { pluginFactory } from '@atlaskit/editor-common/utils/plugin-state-factory';
import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import reducer from './reducer';
import { decorationKey, getAnnotationDiscovery, findAnnotationsInSelection, inlineCommentPluginKey, isBlockNodeAnnotationsSelected, isSelectedAnnotationsChanged } from './utils';
/**
* Creates a document-sync handler with a deleted annotations cache in its closure.
*/
const createHandleDocChanged = () => {
// Cache for preserving deleted annotation resolved states through delete/undo cycles
// This stays outside plugin state so the undo cache is not serialized.
const deletedAnnotationsCache = {};
return (tr, prevPluginState) => {
if (tr.getMeta('replaceDocument')) {
return {
...prevPluginState,
dirtyAnnotations: true
};
}
const updatedState = getSelectionChangedHandler(false)(tr, prevPluginState);
const legacyContentExperimentEnabled = isExperimentEnabled('platform_editor_fix_lcm_editor_annotations');
const currentDiscovery = legacyContentExperimentEnabled ? getAnnotationDiscovery(tr.doc) : undefined;
const previousAnnotationIds = currentDiscovery ? new Set(getAnnotationDiscovery(tr.before).annotationIds) : undefined;
const newNestedAnnotationIds = currentDiscovery ? currentDiscovery.nestedAnnotationIds.filter(id => !(previousAnnotationIds !== null && previousAnnotationIds !== void 0 && previousAnnotationIds.has(id)) && prevPluginState.annotations[id] === undefined && deletedAnnotationsCache[id] === undefined) : [];
// Collect annotation IDs currently in the document
const annotationIdsInDocument = currentDiscovery ? new Set([...currentDiscovery.rootAnnotationIds, ...currentDiscovery.nestedAnnotationIds]) : new Set();
if (!currentDiscovery) {
tr.doc.descendants(node => {
node.marks.forEach(mark => {
if (mark.type.name === 'annotation') {
annotationIdsInDocument.add(mark.attrs.id);
}
});
});
}
const annotationIdsInState = Object.keys(prevPluginState.annotations);
// Early return if annotations haven't changed
const annotationsHaveChanged = annotationIdsInDocument.size !== annotationIdsInState.length || annotationIdsInState.some(id => !annotationIdsInDocument.has(id));
if (!annotationsHaveChanged) {
return newNestedAnnotationIds.length > 0 ? {
...updatedState,
dirtyAnnotations: true
} : updatedState;
}
// Cache deleted annotations to be able to restore their resolved states on undo
const updatedAnnotations = {};
annotationIdsInState.forEach(id => {
if (!annotationIdsInDocument.has(id)) {
deletedAnnotationsCache[id] = prevPluginState.annotations[id];
}
});
// Update annotations to match document state, preserving resolved states through delete/undo
// Only include annotations that have a known resolved state - don't default new annotations to false
// as this would cause them to briefly appear as unresolved before the provider sets their actual state
annotationIdsInDocument.forEach(id => {
var _prevPluginState$anno;
const knownState = (_prevPluginState$anno = prevPluginState.annotations[id]) !== null && _prevPluginState$anno !== void 0 ? _prevPluginState$anno : deletedAnnotationsCache[id];
if (knownState !== undefined) {
updatedAnnotations[id] = knownState;
}
});
return {
...updatedState,
annotations: updatedAnnotations,
...(newNestedAnnotationIds.length > 0 && {
dirtyAnnotations: true
})
};
};
};
/**
* We clear bookmark on the following conditions:
* 1. if current selection is an empty selection, or
* 2. if the current selection and bookmark selection are different
* @param tr
* @param editorState
* @param bookmark
* @example
*/
export const shouldClearBookMarkCheck = (tr, editorState, bookmark) => {
if (editorState.selection.empty || !bookmark) {
return true;
} else if (editorState.selection instanceof NodeSelection) {
const bookmarkSelection = bookmark === null || bookmark === void 0 ? void 0 : bookmark.resolve(tr.doc);
if (bookmarkSelection instanceof NodeSelection) {
const selectionNode = editorState.selection.node;
const bookmarkNode = bookmarkSelection.node;
/**
* Currently, after updating the alt text of a mediaSingle node,
* the selection moves to the media node.
* (then will append a transaction to its parent node)
*/
if (selectionNode.type.name === 'media' && bookmarkNode.type.name === 'mediaSingle') {
var _bookmarkNode$firstCh;
return !((_bookmarkNode$firstCh = bookmarkNode.firstChild) !== null && _bookmarkNode$firstCh !== void 0 && _bookmarkNode$firstCh.eq(selectionNode));
} else {
return !bookmarkNode.eq(selectionNode);
}
}
}
// by default we discard bookmark
return true;
};
const getSelectionChangeHandlerOld = reopenCommentView => (tr, pluginState) => {
if (pluginState.skipSelectionHandling) {
return {
...pluginState,
skipSelectionHandling: false,
...(reopenCommentView && {
isInlineCommentViewClosed: false
})
};
}
if (
// If pluginState.selectedAnnotations is annotations of block node, i.e. when a new comment is created,
// we keep it as it is so that we can show comment view component with the newly created comment
isBlockNodeAnnotationsSelected(tr.selection, pluginState.selectedAnnotations)) {
return {
...pluginState,
...(reopenCommentView && {
isInlineCommentViewClosed: false
})
};
}
const selectedAnnotations = findAnnotationsInSelection(tr.selection, tr.doc);
if (selectedAnnotations.length === 0) {
return {
...pluginState,
selectedAnnotations,
isInlineCommentViewClosed: true,
selectAnnotationMethod: undefined
};
}
if (isSelectedAnnotationsChanged(selectedAnnotations, pluginState.selectedAnnotations)) {
return {
...pluginState,
selectedAnnotations,
selectAnnotationMethod: undefined,
...(reopenCommentView && {
isInlineCommentViewClosed: false
})
};
}
return {
...pluginState,
...(reopenCommentView && {
isInlineCommentViewClosed: false
}),
selectAnnotationMethod: undefined
};
};
const getSelectionChangeHandlerNew = reopenCommentView => (tr, pluginState) => {
if (pluginState.skipSelectionHandling) {
return {
...pluginState,
skipSelectionHandling: false,
...(reopenCommentView && {
isInlineCommentViewClosed: false
})
};
}
const selectedAnnotations = findAnnotationsInSelection(tr.selection, tr.doc);
// NOTE: I've left this commented code here as a reference that the previous old code would reset the selected annotations
// if the selection is empty. If this is no longer needed, we can remove this code.
// if (selectedAnnotations.length === 0) {
// return {
// ...pluginState,
// pendingSelectedAnnotations: selectedAnnotations,
// pendingSelectedAnnotationsUpdateCount:
// pluginState.pendingSelectedAnnotationsUpdateCount + 1,
// isInlineCommentViewClosed: true,
// selectAnnotationMethod: undefined,
// };
// }
if (isSelectedAnnotationsChanged(selectedAnnotations, pluginState.pendingSelectedAnnotations)) {
return {
...pluginState,
pendingSelectedAnnotations: selectedAnnotations,
pendingSelectedAnnotationsUpdateCount: pluginState.pendingSelectedAnnotationsUpdateCount + 1,
...(reopenCommentView && {
isInlineCommentViewClosed: false
})
};
}
return {
...pluginState,
...(reopenCommentView && {
isInlineCommentViewClosed: false
}),
selectAnnotationMethod: undefined
};
};
const getSelectionChangedHandler = reopenCommentView => (tr, pluginState) => pluginState.isAnnotationManagerEnabled ?
// if platform_editor_comments_api_manager == true
getSelectionChangeHandlerNew(reopenCommentView)(tr, pluginState) :
// else if platform_editor_comments_api_manager == false
getSelectionChangeHandlerOld(reopenCommentView)(tr, pluginState);
const createAnnotationPluginFactory = onDocChanged => pluginFactory(inlineCommentPluginKey, reducer, {
onSelectionChanged: getSelectionChangedHandler(true),
onDocChanged,
mapping: (tr, pluginState, editorState) => {
const {
draftDecorationSet,
bookmark
} = pluginState;
let mappedDecorationSet = DecorationSet.empty,
mappedBookmark;
let hasMappedDecorations = false;
if (draftDecorationSet) {
mappedDecorationSet = draftDecorationSet.map(tr.mapping, tr.doc);
}
hasMappedDecorations = mappedDecorationSet.find(undefined, undefined, spec => Object.values(decorationKey).includes(spec.key)).length > 0;
// When changes to decoration target make decoration invalid (e.g. delete text, add mark to node),
// we need to reset bookmark to hide create component and to avoid invalid draft being published
// We only perform this change when document selection has changed.
if (!hasMappedDecorations && shouldClearBookMarkCheck(tr, editorState, bookmark)) {
return {
...pluginState,
draftDecorationSet: mappedDecorationSet,
bookmark: undefined
};
}
if (bookmark) {
mappedBookmark = bookmark.map(tr.mapping);
}
// return same pluginState if mappings did not change
if (mappedBookmark === bookmark && mappedDecorationSet === draftDecorationSet) {
return pluginState;
}
return {
...pluginState,
draftDecorationSet: mappedDecorationSet,
bookmark: mappedBookmark
};
}
});
const handleDocChangedWithSync = createHandleDocChanged();
// Keep the control cohort's existing module-level cache lifetime unchanged.
const legacyDest = createAnnotationPluginFactory(handleDocChangedWithSync);
const commandFactory = pluginFactory(inlineCommentPluginKey, reducer);
export const createPluginState = (dispatch, initialState) => (isExperimentEnabled('platform_editor_fix_lcm_editor_annotations') ? createAnnotationPluginFactory(createHandleDocChanged()) : legacyDest).createPluginState(dispatch, initialState);
export const createCommand = commandFactory.createCommand;