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