UNPKG

@atlaskit/editor-plugin-annotation

Version:

Annotation plugin for @atlaskit/editor-core

459 lines (448 loc) 24.3 kB
import { AnnotationTypes } from '@atlaskit/adf-schema'; import { RESOLVE_METHOD } from '@atlaskit/editor-common/analytics'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { getAnnotationInlineNodeTypes } from '@atlaskit/editor-common/utils'; import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view'; import { fg } from '@atlaskit/platform-feature-flags'; import { clearDirtyMark, closeComponent, setHoveredAnnotation, setInlineCommentsVisibility, setSelectedAnnotation, flushPendingSelections, updateInlineCommentResolvedState, updateMouseState, setPendingSelectedAnnotation, setInlineCommentDraftState, setInlineCommentsFetched } from '../editor-commands'; import { resetUserIntent, setUserIntent } from '../editor-commands/utils'; import { getAnnotationViewClassname, getBlockAnnotationViewClassname } from '../nodeviews'; import { allowAnnotation, applyDraft, clearDraft, clearAnnotation, getDraft, setIsAnnotationHovered, setIsAnnotationSelected, startDraft } from './annotation-manager-hooks'; import { createPluginState } from './plugin-factory'; import { shouldSuppressFloatingToolbar } from './toolbar'; import { decorationKey, getAllAnnotations, getPluginState, inlineCommentPluginKey } from './utils'; const fetchProviderStates = async (provider, annotationIds) => { if (!provider || !provider.getState) { return {}; } const data = await provider.getState(annotationIds); const result = {}; data.forEach(annotation => { if (annotation.annotationType === AnnotationTypes.INLINE_COMMENT) { result[annotation.id] = annotation.state.resolved; } }); return result; }; // fetchState is unable to return a command as it's runs async and may dispatch at a later time // Requires `editorView` instead of the decomposition as the async means state may end up stale const fetchState = async (provider, annotationIds, editorView, editorAnalyticsAPI) => { const inlineCommentStates = await fetchProviderStates(provider, annotationIds); if (Object.keys(inlineCommentStates).length === 0) { const { annotationsLoaded } = getPluginState(editorView.state) || {}; if (!annotationsLoaded && fg('confluence_frontend_new_dangling_comments_ux')) { setInlineCommentsFetched()(editorView.state, editorView.dispatch); } return; } if (editorView.dispatch) { updateInlineCommentResolvedState(editorAnalyticsAPI)(inlineCommentStates)(editorView.state, editorView.dispatch); } }; const initialState = (disallowOnWhitespace = false, featureFlagsPluginState, isAnnotationManagerEnabled = false) => { return { annotationsLoaded: false, annotations: {}, selectedAnnotations: [], hoveredAnnotations: [], mouseData: { isSelecting: false }, disallowOnWhitespace, isInlineCommentViewClosed: false, isVisible: true, skipSelectionHandling: false, featureFlagsPluginState, isDrafting: false, pendingSelectedAnnotations: [], pendingSelectedAnnotationsUpdateCount: 0, isAnnotationManagerEnabled }; }; const hideToolbar = (state, dispatch) => () => { updateMouseState({ isSelecting: true })(state, dispatch); }; // Subscribe to updates from consumer const onResolve = editorAnalyticsAPI => (state, dispatch) => annotationId => { updateInlineCommentResolvedState(editorAnalyticsAPI)({ [annotationId]: true }, RESOLVE_METHOD.CONSUMER)(state, dispatch); }; const onUnResolve = editorAnalyticsAPI => (state, dispatch) => annotationId => { updateInlineCommentResolvedState(editorAnalyticsAPI)({ [annotationId]: false })(state, dispatch); }; const onMouseUp = (state, dispatch) => e => { const { mouseData } = getPluginState(state) || {}; if (mouseData !== null && mouseData !== void 0 && mouseData.isSelecting) { updateMouseState({ isSelecting: false })(state, dispatch); } }; const onSetVisibility = view => isVisible => { const { state, dispatch } = view; setInlineCommentsVisibility(isVisible)(state, dispatch); if (isVisible) { // PM retains focus when we click away from the editor. // This will restore the visual aspect of the selection, // otherwise it will seem a floating toolbar will appear // for no reason. view.focus(); } }; export const inlineCommentPlugin = options => { const { provider, featureFlagsPluginState, annotationManager } = options; return new SafePlugin({ key: inlineCommentPluginKey, state: createPluginState(options.dispatch, initialState(provider.disallowOnWhitespace, featureFlagsPluginState, !!annotationManager)), view(editorView) { let allowAnnotationFn; let startDraftFn; let clearDraftFn; let applyDraftFn; let getDraftFn; let setIsAnnotationSelectedFn; let setIsAnnotationHoveredFn; let clearAnnotationFn; if (annotationManager) { allowAnnotationFn = allowAnnotation(editorView, options); startDraftFn = startDraft(editorView, options); clearDraftFn = clearDraft(editorView, options); applyDraftFn = applyDraft(editorView, options); getDraftFn = getDraft(editorView, options); setIsAnnotationSelectedFn = setIsAnnotationSelected(editorView, options); setIsAnnotationHoveredFn = setIsAnnotationHovered(editorView, options); clearAnnotationFn = clearAnnotation(editorView, options); annotationManager.hook('allowAnnotation', allowAnnotationFn); annotationManager.hook('startDraft', startDraftFn); annotationManager.hook('clearDraft', clearDraftFn); annotationManager.hook('applyDraft', applyDraftFn); annotationManager.hook('getDraft', getDraftFn); annotationManager.hook('setIsAnnotationSelected', setIsAnnotationSelectedFn); annotationManager.hook('setIsAnnotationHovered', setIsAnnotationHoveredFn); annotationManager.hook('clearAnnotation', clearAnnotationFn); } // Get initial state // Need to pass `editorView` to mitigate editor state going stale fetchState(provider, getAllAnnotations(editorView.state.doc), editorView, options.editorAnalyticsAPI); const resolve = annotationId => onResolve(options.editorAnalyticsAPI)(editorView.state, editorView.dispatch)(annotationId); const unResolve = annotationId => onUnResolve(options.editorAnalyticsAPI)(editorView.state, editorView.dispatch)(annotationId); const mouseUp = event => onMouseUp(editorView.state, editorView.dispatch)(event); const setVisibility = isVisible => onSetVisibility(editorView)(isVisible); const setSelectedAnnotationFn = annotationId => { if (!annotationId) { closeComponent()(editorView.state, editorView.dispatch); } else { setSelectedAnnotation(annotationId)(editorView.state, editorView.dispatch); } }; const setHoveredAnnotationFn = annotationId => { if (!annotationId) { closeComponent()(editorView.state, editorView.dispatch); } else { setHoveredAnnotation(annotationId)(editorView.state, editorView.dispatch); } }; const removeHoveredannotationFn = () => { setHoveredAnnotation('')(editorView.state, editorView.dispatch); }; const closeInlineCommentFn = () => { closeComponent()(editorView.state, editorView.dispatch); }; const { updateSubscriber } = provider; if (updateSubscriber) { updateSubscriber.on('resolve', resolve).on('delete', resolve).on('unresolve', unResolve).on('create', unResolve).on('setvisibility', setVisibility).on('setselectedannotation', setSelectedAnnotationFn).on('sethoveredannotation', setHoveredAnnotationFn).on('removehoveredannotation', removeHoveredannotationFn).on('closeinlinecomment', closeInlineCommentFn); } // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners editorView.root.addEventListener('mouseup', mouseUp); /** * This flag is used to prevent the preemptive gate from being called multiple times while a check is in-flight. * If a check is still pending then it's most likely because the product is busy and trying to block the * selection of an annotation. */ let isPreemptiveGateActive = false; return { update(view, _prevState) { var _prevSelectedAnnotati; const { selectedAnnotations, annotations, isDrafting, bookmark } = getPluginState(view.state) || {}; const { selectedAnnotations: prevSelectedAnnotations } = getPluginState(_prevState) || {}; const selectedAnnotationId = selectedAnnotations && selectedAnnotations.length !== 0 && selectedAnnotations[0].id ? selectedAnnotations[0].id : undefined; // If the new state has an unresolved selected annotation, and it's different from // the previous one then... if ( //This checks the selected annotation is different from the previous one selectedAnnotationId && selectedAnnotationId !== (prevSelectedAnnotations === null || prevSelectedAnnotations === void 0 ? void 0 : (_prevSelectedAnnotati = prevSelectedAnnotations[0]) === null || _prevSelectedAnnotati === void 0 ? void 0 : _prevSelectedAnnotati.id) && // This ensures that the selected annotation is unresolved annotations && annotations[selectedAnnotationId] === false) { var _options$selectCommen, _options$viewInlineCo; // ...we mark the select annotation experience as complete. // The selectComponentExperience is using a simplified object, which is why it's type asserted. (_options$selectCommen = options.selectCommentExperience) === null || _options$selectCommen === void 0 ? void 0 : _options$selectCommen.selectAnnotation.complete(selectedAnnotationId); // ...and start a new UFO press trace since the selected comment is changing (_options$viewInlineCo = options.viewInlineCommentTraceUFOPress) === null || _options$viewInlineCo === void 0 ? void 0 : _options$viewInlineCo.call(options); } const { api } = options; if (isDrafting && !view.state.selection.eq(_prevState.selection) && Boolean(api === null || api === void 0 ? void 0 : api.toolbar)) { // It is possible that user update selection while having a active draft, // so we need to reset the user intent to allow inline text toolbar to be visible api === null || api === void 0 ? void 0 : api.core.actions.execute(({ tr }) => { if (shouldSuppressFloatingToolbar({ state: view.state, bookmark })) { setUserIntent(api, tr); } else { resetUserIntent(api, tr); } return tr; }); } if (annotationManager) { // In the Editor, Annotations can be selected in three ways: // 1. By clicking on the annotation in the editor // 2. By using the annotation manager to select the annotation // 3. By moving the cursor to the annotation and using the keyboard to select it // Item 1 & 3 need to be protected by the preemptive gate. This is because these actions could be performed by a user // at a time when changing the selection could cause data loss. // The following preemptive check is designed to cover these items. const { pendingSelectedAnnotations, pendingSelectedAnnotationsUpdateCount } = getPluginState(view.state) || {}; const { pendingSelectedAnnotationsUpdateCount: prevPendingSelectedAnnotationsUpdateCount } = getPluginState(_prevState) || {}; if (!isPreemptiveGateActive && pendingSelectedAnnotationsUpdateCount !== prevPendingSelectedAnnotationsUpdateCount && !!(pendingSelectedAnnotations !== null && pendingSelectedAnnotations !== void 0 && pendingSelectedAnnotations.length)) { // Need to set a lock to avoid calling gate multiple times. The lock will be released // when the preemptive gate is complete. isPreemptiveGateActive = true; annotationManager.checkPreemptiveGate().then(canSelectAnnotation => { const { isDrafting, pendingSelectedAnnotations: latestPendingSelectedAnnotations, selectedAnnotations: latestSelectedAnnotations } = getPluginState(view.state) || {}; if (canSelectAnnotation) { if (isDrafting) { // The user must have chosen to discard there draft. So before we flush the pending selections // we need to clear the draft if there is one. setInlineCommentDraftState(options.editorAnalyticsAPI, undefined, options.api)(false)(view.state, view.dispatch); } // Flush the pending selections into the selected annotations list. flushPendingSelections(options.editorAnalyticsAPI)(true)(view.state, view.dispatch); latestSelectedAnnotations === null || latestSelectedAnnotations === void 0 ? void 0 : latestSelectedAnnotations.filter(annotation => (latestPendingSelectedAnnotations === null || latestPendingSelectedAnnotations === void 0 ? void 0 : latestPendingSelectedAnnotations.findIndex(pendingAnnotation => pendingAnnotation.id === annotation.id)) === -1).forEach(annotation => { var _getAnnotationInlineN; annotationManager.emit({ name: 'annotationSelectionChanged', data: { annotationId: annotation.id, isSelected: false, inlineNodeTypes: (_getAnnotationInlineN = getAnnotationInlineNodeTypes(editorView.state, annotation.id)) !== null && _getAnnotationInlineN !== void 0 ? _getAnnotationInlineN : [] } }); }); // Notify the annotation manager that the pending selection has changed. latestPendingSelectedAnnotations === null || latestPendingSelectedAnnotations === void 0 ? void 0 : latestPendingSelectedAnnotations.forEach(({ id }) => { var _getAnnotationInlineN2; annotationManager.emit({ name: 'annotationSelectionChanged', data: { annotationId: id, isSelected: true, inlineNodeTypes: (_getAnnotationInlineN2 = getAnnotationInlineNodeTypes(view.state, id)) !== null && _getAnnotationInlineN2 !== void 0 ? _getAnnotationInlineN2 : [] } }); }); } else { // Clears the pending selections if the preemptive gate returns false. // We should need to worry about dispatching change events here because the pending selections // are being aborted and the selections will remain unchanged. flushPendingSelections(options.editorAnalyticsAPI)(false)(view.state, view.dispatch); } }).catch(error => { // If an error has occured we will clear any pending selections to avoid accidentally setting the wrong thing. flushPendingSelections(options.editorAnalyticsAPI)(false, 'pending-selection-preemptive-gate-error')(view.state, view.dispatch); }).finally(() => { isPreemptiveGateActive = false; }); } } const { dirtyAnnotations } = getPluginState(view.state) || {}; if (!dirtyAnnotations) { return; } clearDirtyMark()(view.state, view.dispatch); fetchState(provider, getAllAnnotations(view.state.doc), view, options.editorAnalyticsAPI); }, destroy() { // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners editorView.root.removeEventListener('mouseup', mouseUp); if (updateSubscriber) { updateSubscriber.off('resolve', resolve).off('delete', resolve).off('unresolve', unResolve).off('create', unResolve).off('setvisibility', setVisibility).off('setselectedannotation', setSelectedAnnotationFn).off('sethoveredannotation', setHoveredAnnotationFn).off('removehoveredannotation', removeHoveredannotationFn).off('closeinlinecomment', closeInlineCommentFn); } if (annotationManager) { annotationManager.unhook('allowAnnotation', allowAnnotationFn); annotationManager.unhook('startDraft', startDraftFn); annotationManager.unhook('clearDraft', clearDraftFn); annotationManager.unhook('applyDraft', applyDraftFn); annotationManager.unhook('getDraft', getDraftFn); annotationManager.unhook('setIsAnnotationSelected', setIsAnnotationSelectedFn); annotationManager.unhook('setIsAnnotationHovered', setIsAnnotationHoveredFn); annotationManager.unhook('clearAnnotation', clearAnnotationFn); } } }; }, props: { handleDOMEvents: { mousedown: view => { const pluginState = getPluginState(view.state); if (!(pluginState !== null && pluginState !== void 0 && pluginState.mouseData.isSelecting)) { hideToolbar(view.state, view.dispatch)(); } return false; }, dragstart: (view, event) => { // Mouseup won't be triggered after dropping // Hence, update the mouse data to cancel selecting when drag starts return onMouseUp(view.state, view.dispatch)(event); }, click: (view, event) => { var _event$target$closest, _pluginState$selected; if (!(event.target instanceof HTMLElement)) { return false; } // Find the nearest ancestor (or self) with the data-id attribute const annotationId = (_event$target$closest = event.target.closest('[data-id]')) === null || _event$target$closest === void 0 ? void 0 : _event$target$closest.getAttribute('data-id'); if (!annotationId) { return false; } const pluginState = getPluginState(view.state); const isSelected = pluginState === null || pluginState === void 0 ? void 0 : (_pluginState$selected = pluginState.selectedAnnotations) === null || _pluginState$selected === void 0 ? void 0 : _pluginState$selected.some(selectedAnnotation => selectedAnnotation.id === annotationId); // If the annotation is selected and the inline comment view is open, do nothing // as the user is already in the comment view. if (isSelected && !(pluginState !== null && pluginState !== void 0 && pluginState.isInlineCommentViewClosed)) { return false; } const { annotations } = pluginState || {}; const isUnresolved = annotations && annotations[annotationId] === false; if (!isUnresolved) { return false; } if (annotationManager) { var _pluginState$pendingS; // The manager disable setting the selected annotation on click because in the editor this is already // handled by the selection update handler. When the manager is enabled, and a selection changes it's pushed into // the pendingSelectedAnnotations array. This is then used to update the selection when the preemptive gate // is released. const isPendingSelection = pluginState === null || pluginState === void 0 ? void 0 : (_pluginState$pendingS = pluginState.pendingSelectedAnnotations) === null || _pluginState$pendingS === void 0 ? void 0 : _pluginState$pendingS.some(selectedAnnotation => selectedAnnotation.id === annotationId); // If the annotation is selected and the inline comment view is open, do nothing // as the user is already in the comment view. if (isPendingSelection) { return false; } setPendingSelectedAnnotation(annotationId)(view.state, view.dispatch); } else { setSelectedAnnotation(annotationId)(view.state, view.dispatch); } return true; } }, decorations(state) { // highlight comments, depending on state const { draftDecorationSet, annotations, selectedAnnotations, isVisible, isInlineCommentViewClosed, hoveredAnnotations } = getPluginState(state) || {}; let decorations = draftDecorationSet !== null && draftDecorationSet !== void 0 ? draftDecorationSet : DecorationSet.empty; const focusDecorations = []; // TODO: EDITOR-760 - This needs to be optimised, it's not a good idea to scan the entire document // everytime we need to update the decorations. This handler will be called alot. We should be caching // the decorations in plugin state and only updating them when required. state.doc.descendants((node, pos) => { var _provider$supportedBl; // Inline comment on mediaInline is not supported as part of comments on media project // Thus, we skip the decoration for mediaInline node if (node.type.name === 'mediaInline') { return false; } const isSupportedBlockNode = node.isBlock && ((_provider$supportedBl = provider.supportedBlockNodes) === null || _provider$supportedBl === void 0 ? void 0 : _provider$supportedBl.includes(node.type.name)); node.marks.filter(mark => mark.type === state.schema.marks.annotation).forEach(mark => { if (isVisible) { const isUnresolved = !!annotations && annotations[mark.attrs.id] === false; const isSelected = !isInlineCommentViewClosed && !!(selectedAnnotations !== null && selectedAnnotations !== void 0 && selectedAnnotations.some(selectedAnnotation => selectedAnnotation.id === mark.attrs.id)); const isHovered = !isInlineCommentViewClosed && !!(hoveredAnnotations !== null && hoveredAnnotations !== void 0 && hoveredAnnotations.some(hoveredAnnotation => hoveredAnnotation.id === mark.attrs.id)); if (isSupportedBlockNode) { focusDecorations.push(Decoration.node(pos, pos + node.nodeSize, { class: `${getBlockAnnotationViewClassname(isUnresolved, isSelected)} ${isUnresolved}` }, { key: decorationKey.block })); } else { if (fg('editor_inline_comments_on_inline_nodes')) { if (node.isText) { focusDecorations.push(Decoration.inline(pos, pos + node.nodeSize, { class: `${getAnnotationViewClassname(isUnresolved, isSelected, isHovered)} ${isUnresolved}`, nodeName: 'span' })); } else { focusDecorations.push(Decoration.node(pos, pos + node.nodeSize, { class: `${getAnnotationViewClassname(isUnresolved, isSelected, isHovered)} ${isUnresolved}` }, { key: decorationKey.block })); } } else { focusDecorations.push(Decoration.inline(pos, pos + node.nodeSize, { class: `${getAnnotationViewClassname(isUnresolved, isSelected, isHovered)} ${isUnresolved}`, nodeName: 'span' })); } } } }); }); decorations = decorations.add(state.doc, focusDecorations); return decorations; } } }); };