UNPKG

@atlaskit/editor-plugin-annotation

Version:

Annotation plugin for @atlaskit/editor-core

259 lines (246 loc) 9.74 kB
import { pluginFactory } from '@atlaskit/editor-common/utils'; import { NodeSelection } from '@atlaskit/editor-prosemirror/state'; import { DecorationSet } from '@atlaskit/editor-prosemirror/view'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import reducer from './reducer'; import { decorationKey, findAnnotationsInSelection, inlineCommentPluginKey, isBlockNodeAnnotationsSelected, isSelectedAnnotationsChanged } from './utils'; const handleDocChanged = (tr, prevPluginState) => { if (!tr.getMeta('replaceDocument')) { return getSelectionChangedHandler(false)(tr, prevPluginState); } return { ...prevPluginState, dirtyAnnotations: true }; }; /** * Creates a handleDocChanged function with its own deleted annotations cache. * This ensures each editor instance has its own cache, avoiding cross-contamination. */ const createHandleDocChanged = () => { // Cache for preserving deleted annotation resolved states through delete/undo cycles // This lives in closure scope per editor instance to avoid serialization and reduce state size const deletedAnnotationsCache = {}; return (tr, prevPluginState) => { if (tr.getMeta('replaceDocument')) { return { ...prevPluginState, dirtyAnnotations: true }; } const updatedState = getSelectionChangedHandler(false)(tr, prevPluginState); // Collect annotation IDs currently in the document const annotationIdsInDocument = new Set(); 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 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 }; }; }; /** * 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); // Create the handler with cache once at module level const handleDocChangedWithSync = createHandleDocChanged(); const getDocChangedHandler = (tr, prevPluginState) => { // Check feature flag at runtime to support test variants if (expValEquals('platform_editor_annotations_sync_on_docchange', 'isEnabled', true)) { return handleDocChangedWithSync(tr, prevPluginState); } return handleDocChanged(tr, prevPluginState); }; const dest = pluginFactory(inlineCommentPluginKey, reducer, { onSelectionChanged: getSelectionChangedHandler(true), onDocChanged: getDocChangedHandler, 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 }; } }); export const createPluginState = dest.createPluginState; export const createCommand = dest.createCommand;