@atlaskit/editor-plugin-annotation
Version:
Annotation plugin for @atlaskit/editor-core
459 lines (448 loc) • 24.3 kB
JavaScript
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;
}
}
});
};