UNPKG

@atlaskit/editor-plugin-annotation

Version:

Annotation plugin for @atlaskit/editor-core

459 lines (445 loc) 16.6 kB
import { AnnotationTypes } from '@atlaskit/adf-schema'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE } from '@atlaskit/editor-common/analytics'; import { currentMediaNodeWithPos } from '@atlaskit/editor-common/media-single'; import { AnnotationSharedClassNames, BlockAnnotationSharedClassNames } from '@atlaskit/editor-common/styles'; import { canApplyAnnotationOnRange, getAnnotationIdsFromRange, getRangeInlineNodeNames, hasAnnotationMark, isEmptyTextSelection, isParagraph, isText } from '@atlaskit/editor-common/utils'; import { AllSelection, NodeSelection, PluginKey, TextSelection } from '@atlaskit/editor-prosemirror/state'; import { Decoration } from '@atlaskit/editor-prosemirror/view'; import { fg } from '@atlaskit/platform-feature-flags'; import { AnnotationSelectionType } from '../types'; function sum(arr, f) { return arr.reduce((val, x) => val + f(x), 0); } /** * Finds the marks in the nodes to the left and right. * @param $pos Position to center search around */ export const surroundingMarks = $pos => { const { nodeBefore, nodeAfter } = $pos; const markNodeBefore = nodeBefore && $pos.doc.nodeAt(Math.max(0, $pos.pos - nodeBefore.nodeSize - 1)); const markNodeAfter = nodeAfter && $pos.doc.nodeAt($pos.pos + nodeAfter.nodeSize); return [markNodeBefore && markNodeBefore.marks || [], markNodeAfter && markNodeAfter.marks || []]; }; /** * Finds annotation marks, and returns their IDs. * @param marks Array of marks to search in */ const filterAnnotationIds = marks => { if (!marks.length) { return []; } const { annotation } = marks[0].type.schema.marks; return marks.filter(mark => mark.type === annotation).map(mark => mark.attrs.id); }; /** * Re-orders the annotation array based on the order in the document. * * This places the marks that do not appear in the surrounding nodes * higher in the list. That is, the inner-most one appears first. * * Undo, for example, can re-order annotation marks in the document. * @param annotations annotation metadata * @param $from location to look around (usually the selection) */ const reorderAnnotations = (annotations, $from) => { const idSet = surroundingMarks($from).map(filterAnnotationIds); annotations.sort((a, b) => sum(idSet, ids => ids.indexOf(a.id)) - sum(idSet, ids => ids.indexOf(b.id))); }; export const getAllAnnotations = doc => { const allAnnotationIds = new Set(); doc.descendants(node => { node.marks.filter(mark => mark.type.name === 'annotation') // filter out annotations with invalid attributes as they cause errors when interacting with them .filter(validateAnnotationMark).forEach(m => allAnnotationIds.add(m.attrs.id)); return true; }); return Array.from(allAnnotationIds); }; /* * verifies if annotation mark contains valid attributes */ const validateAnnotationMark = annotationMark => { const { id, annotationType } = annotationMark.attrs; return validateAnnotationId(id) && validateAnnotationType(annotationType); function validateAnnotationId(id) { if (!id || typeof id !== 'string') { return false; } const invalidIds = ['null', 'undefined']; return !invalidIds.includes(id.toLowerCase()); } function validateAnnotationType(type) { if (!type || typeof type !== 'string') { return false; } const allowedTypes = Object.values(AnnotationTypes); return allowedTypes.includes(type); } }; export const decorationKey = { block: 'blockCommentDecoration', inline: 'inlineCommentDecoration' }; /* * add decoration for the comment selection in draft state * (when creating new comment) */ export const addDraftDecoration = (start, end, targetType = 'inline') => { if (targetType === 'block') { return Decoration.node(start, end, { class: `${BlockAnnotationSharedClassNames.draft}` }, { key: decorationKey.block }); } return Decoration.inline(start, end, { class: `${AnnotationSharedClassNames.draft}` }, { key: decorationKey.inline }); }; export const getAnnotationViewKey = annotations => { const keys = annotations.map(mark => mark.id).join('_'); return `view-annotation-wrapper_${keys}`; }; export const findAnnotationsInSelection = (selection, doc) => { const { empty, $anchor, anchor } = selection; // Only detect annotations on caret selection if (!empty || !doc) { if (fg('editor_inline_comments_on_inline_nodes')) { if (selection instanceof NodeSelection && ['inlineCard', 'emoji', 'date', 'mention', 'status'].includes(selection.node.type.name)) { // Inline comments on these nodes are supported -- so we continue to find annotations } else { return []; } } else { return []; } } const node = doc.nodeAt(anchor); const nodeBefore = $anchor.nodeBefore; if (!node && !nodeBefore) { return []; } // Inline comment on mediaInline is not supported as part of comments on media project // Hence, we ignore annotations associated with the node when the cursor is right after/before the node if ([nodeBefore, node].some(node => (node === null || node === void 0 ? void 0 : node.type.name) === 'mediaInline')) { return []; } const annotationMark = doc.type.schema.marks.annotation; const anchorAnnotationMarks = (node === null || node === void 0 ? void 0 : node.marks) || []; let marks = []; if (annotationMark.isInSet(anchorAnnotationMarks)) { marks = anchorAnnotationMarks; } if (nodeBefore && annotationMark.isInSet(nodeBefore.marks)) { const existingMarkIds = marks.map(m => m.attrs.id); marks = marks.concat(...nodeBefore.marks.filter(m => !existingMarkIds.includes(m.attrs.id))); } const annotations = marks.filter(mark => mark.type.name === 'annotation').map(mark => ({ id: mark.attrs.id, type: mark.attrs.annotationType })); reorderAnnotations(annotations, $anchor); return annotations; }; export const resolveDraftBookmark = (editorState, bookmark, supportedBlockNodes = []) => { const { doc } = editorState; const resolvedBookmark = bookmark ? bookmark.resolve(doc) : editorState.selection; const { from, to, head } = resolvedBookmark; let draftBookmark = { from, to, head, isBlockNode: false }; if (resolvedBookmark instanceof NodeSelection) { // It's possible that annotation is only allowed in child node instead parent (e.g. mediaSingle vs media), // thus, we traverse the node to find the first node that supports annotation and return its position let nodeFound = false; doc.nodesBetween(from, to, (node, pos) => { // if we find the node, breakout the traversal to make sure we always record the first supported node if (nodeFound) { return false; } const nodeEndsAt = pos + node.nodeSize; if (supportedBlockNodes.includes(node.type.name)) { draftBookmark = { from: pos, to: nodeEndsAt, head: nodeEndsAt, isBlockNode: node.isBlock }; nodeFound = true; return false; } }); } return draftBookmark; }; /** * get selection from position to apply new comment for * @returns bookmarked positions if they exists, otherwise current selection positions */ export function getSelectionPositions(editorState, bookmark) { // get positions via saved bookmark if it is available // this is to make comments box positioned relative to temporary highlight rather then current selection if (bookmark) { return bookmark.resolve(editorState.doc); } return editorState.selection; } export const inlineCommentPluginKey = new PluginKey('inlineCommentPluginKey'); export const getPluginState = state => { return inlineCommentPluginKey.getState(state); }; /** * get number of unique annotations within current selection */ const getAnnotationsInSelectionCount = state => { const { from, to } = state.selection; const annotations = getAnnotationIdsFromRange({ from, to }, state.doc, state.schema); return annotations.length; }; /** * get payload for the open/close analytics event */ export const getDraftCommandAnalyticsPayload = (drafting, inputMethod) => { const payload = state => { let attributes = {}; if (drafting) { attributes = { inputMethod, overlap: getAnnotationsInSelectionCount(state) }; } if (fg('editor_inline_comments_on_inline_nodes')) { const { bookmark } = getPluginState(state) || {}; attributes.inlineNodeNames = getRangeInlineNodeNames({ doc: state.doc, pos: resolveDraftBookmark(state, bookmark) }); } return { action: drafting ? ACTION.OPENED : ACTION.CLOSED, actionSubject: ACTION_SUBJECT.ANNOTATION, actionSubjectId: ACTION_SUBJECT_ID.INLINE_COMMENT, eventType: EVENT_TYPE.TRACK, attributes }; }; return payload; }; export const isSelectionValid = (state, _supportedNodes = []) => { var _currentMediaNodeWith; const { selection } = state; const { disallowOnWhitespace } = getPluginState(state) || {}; const allowedInlineNodes = ['emoji', 'status', 'date', 'mention', 'inlineCard']; const isSelectionEmpty = selection.empty; const isTextOrAllSelection = selection instanceof TextSelection || selection instanceof AllSelection; const isValidNodeSelection = selection instanceof NodeSelection && allowedInlineNodes.includes(selection.node.type.name); const isValidSelection = isTextOrAllSelection || isValidNodeSelection; // Allow media so that it can enter draft mode if ((_currentMediaNodeWith = currentMediaNodeWithPos(state)) !== null && _currentMediaNodeWith !== void 0 && _currentMediaNodeWith.node) { return AnnotationSelectionType.VALID; } if (isSelectionEmpty || !isValidSelection) { return AnnotationSelectionType.INVALID; } const containsInvalidNodes = hasInvalidNodes(state); // A selection that only covers 1 pos, and is an invalid node // e.g. a text selection over a mention if (containsInvalidNodes && selection.to - selection.from === 1) { return AnnotationSelectionType.INVALID; } if (containsInvalidNodes) { return AnnotationSelectionType.DISABLED; } if (disallowOnWhitespace && hasInvalidWhitespaceNode(selection, state.schema)) { return AnnotationSelectionType.INVALID; } if (!(selection instanceof NodeSelection) && isEmptyTextSelection(selection, state.schema)) { return AnnotationSelectionType.INVALID; } return AnnotationSelectionType.VALID; }; export const hasInvalidNodes = state => { const { selection, doc, schema } = state; return !canApplyAnnotationOnRange({ from: selection.from, to: selection.to }, doc, schema); }; export const isSupportedBlockNode = (node, supportedBlockNodes = []) => { return supportedBlockNodes.indexOf(node.type.name) >= 0 || node.type.name === 'mediaSingle' && supportedBlockNodes.indexOf('media') >= 0; }; /** * Checks if any of the nodes in a given selection are completely whitespace * This is to conform to Confluence annotation specifications */ export function hasInvalidWhitespaceNode(selection, schema) { let foundInvalidWhitespace = false; const content = selection.content().content; let hasCommentableInlineNodeDescendants = false; let hasCommentableTextNodeDescendants = false; content.descendants(node => { if (fg('editor_inline_comments_on_inline_nodes')) { const isAllowedInlineNode = ['emoji', 'status', 'date', 'mention', 'inlineCard'].includes(node.type.name); if (isAllowedInlineNode) { hasCommentableInlineNodeDescendants = true; return false; } } if (node.type === schema.nodes.inlineCard && fg('editor_inline_comments_on_inline_nodes')) { return false; } if (isText(node, schema)) { if (fg('editor_inline_comments_on_inline_nodes')) { if (node.textContent.trim() !== '') { hasCommentableTextNodeDescendants = true; } } return false; } if (!fg('editor_inline_comments_on_inline_nodes')) { if (node.textContent.trim() === '') { // Trailing new lines do not result in the annotation spanning into // the trailing new line so can be ignored when looking for invalid // whitespace nodes. const nodeIsTrailingNewLine = // it is the final node // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion node.eq(content.lastChild) && // and there are multiple nodes // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion !node.eq(content.firstChild) && // and it is a paragraph node isParagraph(node, schema); if (!nodeIsTrailingNewLine) { foundInvalidWhitespace = true; } } } return !foundInvalidWhitespace; }); if (fg('editor_inline_comments_on_inline_nodes')) { if (hasCommentableInlineNodeDescendants) { return false; } return !hasCommentableTextNodeDescendants; } return foundInvalidWhitespace; } /* * verifies that the annotation exists by the given id */ export function annotationExists(annotationId, state) { const commentsPluginState = getPluginState(state); return !!(commentsPluginState !== null && commentsPluginState !== void 0 && commentsPluginState.annotations) && Object.keys(commentsPluginState.annotations).includes(annotationId); } /* * remove annotations that dont exsist in plugin state from slice */ export function stripNonExistingAnnotations(slice, state) { if (!slice.content.size) { return false; } slice.content.forEach(node => { stripNonExistingAnnotationsFromNode(node, state); node.content.descendants(node => { stripNonExistingAnnotationsFromNode(node, state); return true; }); }); } /* * remove annotations that dont exsist in plugin state * from node */ function stripNonExistingAnnotationsFromNode(node, state) { if (hasAnnotationMark(node, state)) { node.marks = node.marks.filter(mark => { if (mark.type.name === 'annotation') { return annotationExists(mark.attrs.id, state); } return true; }); } return node; } /** * Compares two sets of annotationInfos to see if the annotations have changed * This function assumes annotations will have unique id's for simplicity */ export function isSelectedAnnotationsChanged(oldSelectedAnnotations, newSelectedAnnotations) { return newSelectedAnnotations.length !== oldSelectedAnnotations.length || // assuming annotations have unique id's for simplicity newSelectedAnnotations.some(annotation => !oldSelectedAnnotations.find(pluginStateAnnotation => annotation.id === pluginStateAnnotation.id && annotation.type === pluginStateAnnotation.type)); } /** * Checks if the selectedAnnotations are the same as the annotations on the selected block node */ export const isBlockNodeAnnotationsSelected = (selection, selectedAnnotations = []) => { if (selectedAnnotations.length && selection instanceof NodeSelection) { const node = selection.node.type.name === 'mediaSingle' ? selection.node.firstChild : selection.node; const annotationMarks = (node === null || node === void 0 ? void 0 : node.marks.filter(mark => mark.type.name === 'annotation').map(mark => ({ id: mark.attrs.id, type: mark.attrs.annotationType }))) || []; return !selectedAnnotations.some(annotation => !annotationMarks.find(existingAnnotation => existingAnnotation.id === annotation.id && existingAnnotation.type === annotation.type)); } return false; }; export const hasAnyUnResolvedAnnotationInPage = state => { var _getPluginState; const annotations = (_getPluginState = getPluginState(state)) === null || _getPluginState === void 0 ? void 0 : _getPluginState.annotations; if (annotations) { /** * annotations type is { [key: string]: boolean }; * Here, key represents mark.attr.id and it is used to find where annotation to be presented in the document. * When value is false, it means it is unresolved annotation. * * But sometimes annotation map has entry with key undefined somehow. * And it is not valid mark attribute id, so it won't be presented anywhere in the document. */ const unresolvedAnnotationKeys = Object.keys(annotations).filter(key => key !== 'undefined' && annotations[key] === false); return unresolvedAnnotationKeys.length > 0; } return false; };