UNPKG

@atlaskit/editor-plugin-annotation

Version:

Annotation plugin for @atlaskit/editor-core

507 lines (492 loc) 20 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.addDraftDecoration = void 0; exports.annotationExists = annotationExists; exports.getPluginState = exports.getDraftCommandAnalyticsPayload = exports.getAnnotationViewKey = exports.getAllAnnotations = exports.findAnnotationsInSelection = exports.decorationKey = void 0; exports.getSelectionPositions = getSelectionPositions; exports.hasInvalidNodes = exports.hasAnyUnResolvedAnnotationInPage = void 0; exports.hasInvalidWhitespaceNode = hasInvalidWhitespaceNode; exports.isBlockNodeAnnotationsSelected = exports.inlineCommentPluginKey = void 0; exports.isSelectedAnnotationsChanged = isSelectedAnnotationsChanged; exports.resolveDraftBookmark = exports.isSupportedBlockNode = exports.isSelectionValid = void 0; exports.stripNonExistingAnnotations = stripNonExistingAnnotations; exports.surroundingMarks = void 0; var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray")); var _adfSchema = require("@atlaskit/adf-schema"); var _analytics = require("@atlaskit/editor-common/analytics"); var _mediaSingle = require("@atlaskit/editor-common/media-single"); var _styles = require("@atlaskit/editor-common/styles"); var _utils = require("@atlaskit/editor-common/utils"); var _state = require("@atlaskit/editor-prosemirror/state"); var _view = require("@atlaskit/editor-prosemirror/view"); var _platformFeatureFlags = require("@atlaskit/platform-feature-flags"); var _types = require("../types"); function sum(arr, f) { return arr.reduce(function (val, x) { return val + f(x); }, 0); } /** * Finds the marks in the nodes to the left and right. * @param $pos Position to center search around */ var surroundingMarks = exports.surroundingMarks = function surroundingMarks($pos) { var nodeBefore = $pos.nodeBefore, nodeAfter = $pos.nodeAfter; var markNodeBefore = nodeBefore && $pos.doc.nodeAt(Math.max(0, $pos.pos - nodeBefore.nodeSize - 1)); var 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 */ var filterAnnotationIds = function filterAnnotationIds(marks) { if (!marks.length) { return []; } var annotation = marks[0].type.schema.marks.annotation; return marks.filter(function (mark) { return mark.type === annotation; }).map(function (mark) { return 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) */ var reorderAnnotations = function reorderAnnotations(annotations, $from) { var idSet = surroundingMarks($from).map(filterAnnotationIds); annotations.sort(function (a, b) { return sum(idSet, function (ids) { return ids.indexOf(a.id); }) - sum(idSet, function (ids) { return ids.indexOf(b.id); }); }); }; var getAllAnnotations = exports.getAllAnnotations = function getAllAnnotations(doc) { var allAnnotationIds = new Set(); doc.descendants(function (node) { node.marks.filter(function (mark) { return mark.type.name === 'annotation'; }) // filter out annotations with invalid attributes as they cause errors when interacting with them .filter(validateAnnotationMark).forEach(function (m) { return allAnnotationIds.add(m.attrs.id); }); return true; }); return Array.from(allAnnotationIds); }; /* * verifies if annotation mark contains valid attributes */ var validateAnnotationMark = function validateAnnotationMark(annotationMark) { var _ref = annotationMark.attrs, id = _ref.id, annotationType = _ref.annotationType; return validateAnnotationId(id) && validateAnnotationType(annotationType); function validateAnnotationId(id) { if (!id || typeof id !== 'string') { return false; } var invalidIds = ['null', 'undefined']; return !invalidIds.includes(id.toLowerCase()); } function validateAnnotationType(type) { if (!type || typeof type !== 'string') { return false; } var allowedTypes = Object.values(_adfSchema.AnnotationTypes); return allowedTypes.includes(type); } }; var decorationKey = exports.decorationKey = { block: 'blockCommentDecoration', inline: 'inlineCommentDecoration' }; /* * add decoration for the comment selection in draft state * (when creating new comment) */ var addDraftDecoration = exports.addDraftDecoration = function addDraftDecoration(start, end) { var targetType = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'inline'; if (targetType === 'block') { return _view.Decoration.node(start, end, { class: "".concat(_styles.BlockAnnotationSharedClassNames.draft) }, { key: decorationKey.block }); } return _view.Decoration.inline(start, end, { class: "".concat(_styles.AnnotationSharedClassNames.draft) }, { key: decorationKey.inline }); }; var getAnnotationViewKey = exports.getAnnotationViewKey = function getAnnotationViewKey(annotations) { var keys = annotations.map(function (mark) { return mark.id; }).join('_'); return "view-annotation-wrapper_".concat(keys); }; var findAnnotationsInSelection = exports.findAnnotationsInSelection = function findAnnotationsInSelection(selection, doc) { var empty = selection.empty, $anchor = selection.$anchor, anchor = selection.anchor; // Only detect annotations on caret selection if (!empty || !doc) { if ((0, _platformFeatureFlags.fg)('editor_inline_comments_on_inline_nodes')) { if (selection instanceof _state.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 []; } } var node = doc.nodeAt(anchor); var 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(function (node) { return (node === null || node === void 0 ? void 0 : node.type.name) === 'mediaInline'; })) { return []; } var annotationMark = doc.type.schema.marks.annotation; var anchorAnnotationMarks = (node === null || node === void 0 ? void 0 : node.marks) || []; var marks = []; if (annotationMark.isInSet(anchorAnnotationMarks)) { marks = anchorAnnotationMarks; } if (nodeBefore && annotationMark.isInSet(nodeBefore.marks)) { var _marks; var existingMarkIds = marks.map(function (m) { return m.attrs.id; }); marks = (_marks = marks).concat.apply(_marks, (0, _toConsumableArray2.default)(nodeBefore.marks.filter(function (m) { return !existingMarkIds.includes(m.attrs.id); }))); } var annotations = marks.filter(function (mark) { return mark.type.name === 'annotation'; }).map(function (mark) { return { id: mark.attrs.id, type: mark.attrs.annotationType }; }); reorderAnnotations(annotations, $anchor); return annotations; }; var resolveDraftBookmark = exports.resolveDraftBookmark = function resolveDraftBookmark(editorState, bookmark) { var supportedBlockNodes = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : []; var doc = editorState.doc; var resolvedBookmark = bookmark ? bookmark.resolve(doc) : editorState.selection; var from = resolvedBookmark.from, to = resolvedBookmark.to, head = resolvedBookmark.head; var draftBookmark = { from: from, to: to, head: head, isBlockNode: false }; if (resolvedBookmark instanceof _state.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 var nodeFound = false; doc.nodesBetween(from, to, function (node, pos) { // if we find the node, breakout the traversal to make sure we always record the first supported node if (nodeFound) { return false; } var 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 */ 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; } var inlineCommentPluginKey = exports.inlineCommentPluginKey = new _state.PluginKey('inlineCommentPluginKey'); var getPluginState = exports.getPluginState = function getPluginState(state) { return inlineCommentPluginKey.getState(state); }; /** * get number of unique annotations within current selection */ var getAnnotationsInSelectionCount = function getAnnotationsInSelectionCount(state) { var _state$selection = state.selection, from = _state$selection.from, to = _state$selection.to; var annotations = (0, _utils.getAnnotationIdsFromRange)({ from: from, to: to }, state.doc, state.schema); return annotations.length; }; /** * get payload for the open/close analytics event */ var getDraftCommandAnalyticsPayload = exports.getDraftCommandAnalyticsPayload = function getDraftCommandAnalyticsPayload(drafting, inputMethod) { var payload = function payload(state) { var attributes = {}; if (drafting) { attributes = { inputMethod: inputMethod, overlap: getAnnotationsInSelectionCount(state) }; } if ((0, _platformFeatureFlags.fg)('editor_inline_comments_on_inline_nodes')) { var _ref2 = getPluginState(state) || {}, bookmark = _ref2.bookmark; attributes.inlineNodeNames = (0, _utils.getRangeInlineNodeNames)({ doc: state.doc, pos: resolveDraftBookmark(state, bookmark) }); } return { action: drafting ? _analytics.ACTION.OPENED : _analytics.ACTION.CLOSED, actionSubject: _analytics.ACTION_SUBJECT.ANNOTATION, actionSubjectId: _analytics.ACTION_SUBJECT_ID.INLINE_COMMENT, eventType: _analytics.EVENT_TYPE.TRACK, attributes: attributes }; }; return payload; }; var isSelectionValid = exports.isSelectionValid = function isSelectionValid(state) { var _currentMediaNodeWith; var _supportedNodes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; var selection = state.selection; var _ref3 = getPluginState(state) || {}, disallowOnWhitespace = _ref3.disallowOnWhitespace; var allowedInlineNodes = ['emoji', 'status', 'date', 'mention', 'inlineCard']; var isSelectionEmpty = selection.empty; var isTextOrAllSelection = selection instanceof _state.TextSelection || selection instanceof _state.AllSelection; var isValidNodeSelection = selection instanceof _state.NodeSelection && allowedInlineNodes.includes(selection.node.type.name); var isValidSelection = isTextOrAllSelection || isValidNodeSelection; // Allow media so that it can enter draft mode if ((_currentMediaNodeWith = (0, _mediaSingle.currentMediaNodeWithPos)(state)) !== null && _currentMediaNodeWith !== void 0 && _currentMediaNodeWith.node) { return _types.AnnotationSelectionType.VALID; } if (isSelectionEmpty || !isValidSelection) { return _types.AnnotationSelectionType.INVALID; } var 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 _types.AnnotationSelectionType.INVALID; } if (containsInvalidNodes) { return _types.AnnotationSelectionType.DISABLED; } if (disallowOnWhitespace && hasInvalidWhitespaceNode(selection, state.schema)) { return _types.AnnotationSelectionType.INVALID; } if (!(selection instanceof _state.NodeSelection) && (0, _utils.isEmptyTextSelection)(selection, state.schema)) { return _types.AnnotationSelectionType.INVALID; } return _types.AnnotationSelectionType.VALID; }; var hasInvalidNodes = exports.hasInvalidNodes = function hasInvalidNodes(state) { var selection = state.selection, doc = state.doc, schema = state.schema; return !(0, _utils.canApplyAnnotationOnRange)({ from: selection.from, to: selection.to }, doc, schema); }; var isSupportedBlockNode = exports.isSupportedBlockNode = function isSupportedBlockNode(node) { var supportedBlockNodes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; 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 */ function hasInvalidWhitespaceNode(selection, schema) { var foundInvalidWhitespace = false; var content = selection.content().content; var hasCommentableInlineNodeDescendants = false; var hasCommentableTextNodeDescendants = false; content.descendants(function (node) { if ((0, _platformFeatureFlags.fg)('editor_inline_comments_on_inline_nodes')) { var isAllowedInlineNode = ['emoji', 'status', 'date', 'mention', 'inlineCard'].includes(node.type.name); if (isAllowedInlineNode) { hasCommentableInlineNodeDescendants = true; return false; } } if (node.type === schema.nodes.inlineCard && (0, _platformFeatureFlags.fg)('editor_inline_comments_on_inline_nodes')) { return false; } if ((0, _utils.isText)(node, schema)) { if ((0, _platformFeatureFlags.fg)('editor_inline_comments_on_inline_nodes')) { if (node.textContent.trim() !== '') { hasCommentableTextNodeDescendants = true; } } return false; } if (!(0, _platformFeatureFlags.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. var 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 (0, _utils.isParagraph)(node, schema); if (!nodeIsTrailingNewLine) { foundInvalidWhitespace = true; } } } return !foundInvalidWhitespace; }); if ((0, _platformFeatureFlags.fg)('editor_inline_comments_on_inline_nodes')) { if (hasCommentableInlineNodeDescendants) { return false; } return !hasCommentableTextNodeDescendants; } return foundInvalidWhitespace; } /* * verifies that the annotation exists by the given id */ function annotationExists(annotationId, state) { var 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 */ function stripNonExistingAnnotations(slice, state) { if (!slice.content.size) { return false; } slice.content.forEach(function (node) { stripNonExistingAnnotationsFromNode(node, state); node.content.descendants(function (node) { stripNonExistingAnnotationsFromNode(node, state); return true; }); }); } /* * remove annotations that dont exsist in plugin state * from node */ function stripNonExistingAnnotationsFromNode(node, state) { if ((0, _utils.hasAnnotationMark)(node, state)) { node.marks = node.marks.filter(function (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 */ function isSelectedAnnotationsChanged(oldSelectedAnnotations, newSelectedAnnotations) { return newSelectedAnnotations.length !== oldSelectedAnnotations.length || // assuming annotations have unique id's for simplicity newSelectedAnnotations.some(function (annotation) { return !oldSelectedAnnotations.find(function (pluginStateAnnotation) { return annotation.id === pluginStateAnnotation.id && annotation.type === pluginStateAnnotation.type; }); }); } /** * Checks if the selectedAnnotations are the same as the annotations on the selected block node */ var isBlockNodeAnnotationsSelected = exports.isBlockNodeAnnotationsSelected = function isBlockNodeAnnotationsSelected(selection) { var selectedAnnotations = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; if (selectedAnnotations.length && selection instanceof _state.NodeSelection) { var node = selection.node.type.name === 'mediaSingle' ? selection.node.firstChild : selection.node; var annotationMarks = (node === null || node === void 0 ? void 0 : node.marks.filter(function (mark) { return mark.type.name === 'annotation'; }).map(function (mark) { return { id: mark.attrs.id, type: mark.attrs.annotationType }; })) || []; return !selectedAnnotations.some(function (annotation) { return !annotationMarks.find(function (existingAnnotation) { return existingAnnotation.id === annotation.id && existingAnnotation.type === annotation.type; }); }); } return false; }; var hasAnyUnResolvedAnnotationInPage = exports.hasAnyUnResolvedAnnotationInPage = function hasAnyUnResolvedAnnotationInPage(state) { var _getPluginState; var 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. */ var unresolvedAnnotationKeys = Object.keys(annotations).filter(function (key) { return key !== 'undefined' && annotations[key] === false; }); return unresolvedAnnotationKeys.length > 0; } return false; };