@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
221 lines (215 loc) • 7.96 kB
JavaScript
import clamp from 'lodash/clamp';
import { ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { isEmptyParagraph } from './editor-core-utils';
export const getStepRange = transaction => {
let from = -1;
let to = -1;
transaction.mapping.maps.forEach((stepMap, index) => {
stepMap.forEach((oldStart, oldEnd) => {
const newStart = transaction.mapping.slice(index).map(oldStart, -1);
const newEnd = transaction.mapping.slice(index).map(oldEnd);
const docSize = transaction.doc.content.size;
from = clamp(newStart < from || from === -1 ? newStart : from, 0, docSize);
to = clamp(newEnd > to || to === -1 ? newEnd : to, 0, docSize);
});
});
if (from !== -1) {
return {
from,
to
};
}
return null;
};
// Checks to see if the parent node is the document, ie not contained within another entity
export function hasDocAsParent($anchor) {
return $anchor.depth === 1;
}
/**
* Checks if a node looks like an empty document
*/
export function isEmptyDocument(node) {
const nodeChild = node.content.firstChild;
if (node.childCount !== 1 || !nodeChild) {
return false;
}
return isEmptyParagraph(nodeChild);
}
export function bracketTyped(state) {
const {
selection
} = state;
const {
$cursor,
$anchor
} = selection;
if (!$cursor) {
return false;
}
const node = $cursor.nodeBefore;
if (!node) {
return false;
}
if (node.type.name === 'text' && node.text === '{') {
const paragraphNode = $anchor.node();
return paragraphNode.marks.length === 0 && hasDocAsParent($anchor);
}
return false;
}
export function nodesBetweenChanged(tr, f, startPos) {
const stepRange = getStepRange(tr);
if (!stepRange) {
return;
}
tr.doc.nodesBetween(stepRange.from, stepRange.to, f, startPos);
}
/**
* Returns false if node contains only empty inline nodes and hardBreaks.
*/
export function hasVisibleContent(node) {
const isInlineNodeHasVisibleContent = inlineNode => {
return inlineNode.isText ? !!inlineNode.textContent.trim() : inlineNode.type.name !== 'hardBreak';
};
if (node.isInline) {
return isInlineNodeHasVisibleContent(node);
} else if (node.isBlock && (node.isLeaf || node.isAtom)) {
return true;
} else if (!node.childCount) {
return false;
}
for (let index = 0; index < node.childCount; index++) {
const child = node.child(index);
const invisibleNodeTypes = ['paragraph', 'text', 'hardBreak'];
if (!invisibleNodeTypes.includes(child.type.name) || hasVisibleContent(child)) {
return true;
}
}
return false;
}
export const isSelectionEndOfParagraph = state => state.selection.$to.parent.type === state.schema.nodes.paragraph && state.selection.$to.pos === state.doc.resolve(state.selection.$to.pos).end();
function getChangedNodesIn({
tr,
doc
}) {
const nodes = [];
const stepRange = getStepRange(tr);
if (!stepRange) {
return nodes;
}
const from = Math.min(doc.nodeSize - 2, stepRange.from);
const to = Math.min(doc.nodeSize - 2, stepRange.to);
doc.nodesBetween(from, to, (node, pos) => {
nodes.push({
node,
pos
});
});
return nodes;
}
export function getChangedNodes(tr) {
return getChangedNodesIn({
tr: tr,
doc: tr.doc
});
}
// When document first load in Confluence, initially it is an empty document
// and Collab service triggers a transaction to replace the empty document with the real document that should be rendered.
// isReplaceDocumentOperation is checking if the transaction is the one that replace the empty document with the real document
export const isReplaceDocOperation = (transactions, oldState) => {
return transactions.some(tr => {
if (tr.getMeta('replaceDocument')) {
return true;
}
const hasStepReplacingEntireDocument = tr.steps.some(step => {
if (!(step instanceof ReplaceStep)) {
return false;
}
const isStepReplacingFromDocStart = step.from === 0;
const isStepReplacingUntilTheEndOfDocument = step.to === oldState.doc.content.size;
if (!isStepReplacingFromDocStart || !isStepReplacingUntilTheEndOfDocument) {
return false;
}
return true;
});
return hasStepReplacingEntireDocument;
});
};
function marksEqualInOrder(m1, m2) {
if (m1.length !== m2.length) return false;
return m1.every((m, i) => m.eq(m2[i]));
}
function marksEqualIgnoringOrder(m1, m2) {
if (m1.length !== m2.length) {
return false;
}
const m2Used = new Set();
for (const mark1 of m1) {
const idx = m2.findIndex((mark2, i) => !m2Used.has(i) && mark1.eq(mark2));
if (idx === -1) {
return false;
}
m2Used.add(idx);
}
return true;
}
/**
* Compares two ProseMirror documents for equality, ignoring attributes
* which don't affect the document structure.
*
* This is almost a copy of the .eq() PM function - tweaked to ignore attrs
*
* @param doc1 PMNode
* @param doc2 PMNode
* @param attributesToIgnore Specific array of attribute keys to ignore - defaults to ignoring all
* @param opts.ignoreMarkOrder If mark order should be ignored to still be equal (e.g. reversed annotation marks). When not provided, controlled by platform_editor_are_nodes_equal_ignore_mark_order feature gate (defaults to true when gate is on).
* @returns boolean
*/
export function areNodesEqualIgnoreAttrs(node1, node2, attributesToIgnore, opts) {
var _opts$ignoreMarkOrder;
const ignoreMarkOrder = (_opts$ignoreMarkOrder = opts === null || opts === void 0 ? void 0 : opts.ignoreMarkOrder) !== null && _opts$ignoreMarkOrder !== void 0 ? _opts$ignoreMarkOrder : expValEquals('platform_editor_are_nodes_equal_ignore_mark_order', 'isEnabled', true);
if (node1.isText) {
if (ignoreMarkOrder) {
return node1.text === node2.text && marksEqualIgnoringOrder(node1.marks, node2.marks);
}
return node1.eq(node2);
}
const marksEqual = ignoreMarkOrder ? marksEqualIgnoringOrder(node1.marks, node2.marks) : marksEqualInOrder(node1.marks, node2.marks);
// If no attributes to ignore, compare all attributes
if (!attributesToIgnore || attributesToIgnore.length === 0) {
if (expValEquals('platform_editor_are_nodes_equal_ignore_mark_order', 'isEnabled', true)) {
return node1 === node2 || node1.hasMarkup(node2.type, node1.attrs, node1.marks) && marksEqual && areFragmentsEqual(node1.content, node2.content, undefined, opts);
} else {
return node1 === node2 || node1.hasMarkup(node2.type, node1.attrs, node2.marks) && areFragmentsEqual(node1.content, node2.content);
}
}
// Build attrs to compare by excluding ignored attributes
const attrsToCompare = expValEquals('platform_editor_show_diff_fix_missing_attrs', 'isEnabled', true) ? {
...node2.attrs
} : node2.attrs;
const ignoreSet = new Set(attributesToIgnore);
for (const key in node2.attrs) {
if (ignoreSet.has(key)) {
attrsToCompare[key] = node1.attrs[key];
}
}
if (expValEquals('platform_editor_are_nodes_equal_ignore_mark_order', 'isEnabled', true)) {
return node1 === node2 || node1.type === node2.type && node1.hasMarkup(node2.type, attrsToCompare, node1.marks) && marksEqual && areFragmentsEqual(node1.content, node2.content, attributesToIgnore, opts);
} else {
return node1 === node2 || node1.hasMarkup(node2.type, attrsToCompare, node2.marks) && areFragmentsEqual(node1.content, node2.content, attributesToIgnore);
}
}
function areFragmentsEqual(frag1, frag2, attributesToIgnore, opts) {
if (frag1.content.length !== frag2.content.length) {
return false;
}
let childrenEqual = true;
frag1.content.forEach((child, i) => {
const otherChild = frag2.child(i);
if (child === otherChild || otherChild && areNodesEqualIgnoreAttrs(child, otherChild, attributesToIgnore, opts)) {
return;
}
childrenEqual = false;
});
return childrenEqual;
}