@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
938 lines (927 loc) • 23 kB
JavaScript
import { inlineNodes, isSafeUrl, PanelType, generateUuid as uuid } from '@atlaskit/adf-schema';
import { defaultSchema } from '@atlaskit/adf-schema/schema-default';
import { getBooleanFF } from '@atlaskit/platform-feature-flags';
export const ADFStages = {
FINAL: 'final',
STAGE_0: 'stage0'
};
/*
* An ADF Document JSON object. The document is the root node and documents are
* composed of nodes. This type accepts an array of ADNode types as content.
*
* It is basically the same as the JSONNodeDoc interface from editor-json-transformer.
*
* Do not use this type for content nodes as they require additional attributes.
*
* Use ADNode instead for content nodes (any node other than the doc).
*/
/*
* An ADF Node object. This type is used as content for the ADDoc interface.
* It is basically the same as the JSONNode type from editor-json-transformer
* but the types are a little more strict.
*
* It is a serialisable form of ADFEntity.
*
* Do not use this for ADF documents - they should use the ADDoc interface.
*/
/*
* It's important that this order follows the marks rank defined here:
* https://product-fabric.atlassian.net/wiki/spaces/E/pages/11174043/Document+structure#Documentstructure-Rank
*/
export const markOrder = ['fragment', 'link', 'em', 'strong', 'textColor', 'strike', 'subsup', 'underline', 'code', 'confluenceInlineComment', 'annotation', 'dataConsumer'];
export const isSubSupType = type => {
return type === 'sub' || type === 'sup';
};
/*
* Sorts mark by the predefined order above
*/
export const getMarksByOrder = marks => {
return [...marks].sort((a, b) => markOrder.indexOf(a.type.name) - markOrder.indexOf(b.type.name));
};
/*
* Check if two marks are the same by comparing type and attrs
*/
export const isSameMark = (mark, otherMark) => {
if (!mark || !otherMark) {
return false;
}
return mark.eq(otherMark);
};
export const getValidDocument = (doc, schema = defaultSchema, adfStage = 'final') => {
const node = getValidNode(doc, schema, adfStage);
if (node.type === 'doc') {
node.content = wrapInlineNodes(node.content);
return node;
}
return null;
};
const wrapInlineNodes = (nodes = []) => {
return nodes.map(node => inlineNodes.has(node.type) ? {
type: 'paragraph',
content: [node]
} : node);
};
export const getValidContent = (content, schema = defaultSchema, adfStage = 'final') => {
return content.map(node => getValidNode(node, schema, adfStage));
};
const TEXT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
const RELATIVE_LINK = /^\//;
const ANCHOR_LINK = /^#/;
const flattenUnknownBlockTree = (node, schema = defaultSchema, adfStage = 'final') => {
const output = [];
let isPrevLeafNode = false;
for (let i = 0; i < node.content.length; i++) {
const childNode = node.content[i];
const isLeafNode = !(childNode.content && childNode.content.length);
if (i > 0) {
if (isPrevLeafNode) {
output.push({
type: 'text',
text: ' '
});
} else {
output.push({
type: 'hardBreak'
});
}
}
if (isLeafNode) {
output.push(getValidNode(childNode, schema, adfStage));
} else {
output.push(...flattenUnknownBlockTree(childNode, schema, adfStage));
}
isPrevLeafNode = isLeafNode;
}
return output;
};
/**
* Sanitize unknown node tree
*
* @see https://product-fabric.atlassian.net/wiki/spaces/E/pages/11174043/Document+structure#Documentstructure-ImplementationdetailsforHCNGwebrenderer
*/
export const getValidUnknownNode = node => {
const {
attrs = {},
content,
text,
type
} = node;
if (!content || !content.length) {
const unknownInlineNode = {
type: 'text',
text: text || attrs.text || `[${type}]`
};
const {
textUrl
} = attrs;
if (textUrl && isSafeUrl(textUrl)) {
unknownInlineNode.marks = [{
type: 'link',
attrs: {
href: textUrl
}
}];
}
return unknownInlineNode;
}
/*
* Find leaf nodes and join them. If leaf nodes' parent node is the same node
* join with a blank space, otherwise they are children of different branches, i.e.
* we need to join them with a hardBreak node
*/
return {
type: 'unknownBlock',
content: flattenUnknownBlockTree(node)
};
};
const getValidMarks = (marks, adfStage = 'final') => {
if (marks && marks.length > 0) {
return marks.reduce((acc, mark) => {
const validMark = getValidMark(mark, adfStage);
if (validMark) {
acc.push(validMark);
}
return acc;
}, []);
}
return marks;
};
/*
* This method will validate a Node according to the spec defined here
* https://product-fabric.atlassian.net/wiki/spaces/E/pages/11174043/Document+structure#Documentstructure-Nodes
*
* This is also the place to handle backwards compatibility.
*
* If a node is not recognized or is missing required attributes, we should return 'unknown'
*
*/
export const getValidNode = (originalNode, schema = defaultSchema, adfStage = 'final') => {
const {
attrs,
marks,
text,
type
} = originalNode;
let {
content
} = originalNode;
const node = {
attrs,
marks,
text,
type
};
if (content) {
node.content = content = getValidContent(content, schema, adfStage);
}
// If node type doesn't exist in schema, make it an unknown node
if (!schema.nodes[type]) {
return getValidUnknownNode(node);
}
if (type) {
switch (type) {
case 'doc':
{
const {
version
} = originalNode;
if (version && content && content.length) {
return {
type,
content
};
}
break;
}
case 'codeBlock':
{
if (content) {
content = content.reduce((acc, val) => {
if (val.type === 'text') {
acc.push({
type: val.type,
text: val.text
});
}
return acc;
}, []);
}
if (attrs && attrs.language) {
return {
type,
attrs,
content,
marks
};
}
return {
type,
content,
marks
};
}
case 'date':
{
if (attrs && attrs.timestamp) {
return {
type,
attrs
};
}
break;
}
case 'status':
{
if (attrs && attrs.text && attrs.color) {
return {
type,
attrs
};
}
break;
}
case 'emoji':
{
if (attrs && attrs.shortName) {
return {
type,
attrs
};
}
break;
}
case 'inlineExtension':
case 'extension':
{
if (attrs && attrs.extensionType && attrs.extensionKey) {
return {
type,
attrs
};
}
break;
}
case 'inlineCard':
{
if (getBooleanFF('platform.editor.allow-inline-comments-for-inline-nodes')) {
let inlineCardNode = {
type
};
if (attrs && (attrs.datasource && !attrs.url || attrs.url && isSafeUrl(attrs.url) || attrs.data && attrs.data.url && isSafeUrl(attrs.data.url))) {
inlineCardNode.attrs = {
...attrs
};
}
if (marks) {
inlineCardNode.marks = [...marks];
}
return inlineCardNode;
} else {
if (attrs && (attrs.datasource && !attrs.url || attrs.url && isSafeUrl(attrs.url) || attrs.data && attrs.data.url && isSafeUrl(attrs.data.url))) {
return {
type,
attrs
};
}
break;
}
}
case 'blockCard':
{
if (attrs && (attrs.datasource && !attrs.url || attrs.url && isSafeUrl(attrs.url) || attrs.data && attrs.data.url && isSafeUrl(attrs.data.url))) {
return {
type,
attrs
};
}
break;
}
case 'embedCard':
{
if (attrs && (attrs.url && isSafeUrl(attrs.url) || attrs.data && attrs.data.url && isSafeUrl(attrs.data.url)) && attrs.layout) {
return {
type,
attrs
};
}
break;
}
case 'bodiedExtension':
{
if (attrs && attrs.extensionType && attrs.extensionKey && content) {
return {
type,
attrs,
content
};
}
break;
}
case 'multiBodiedExtension':
{
if (attrs && attrs.extensionType && attrs.extensionKey && content) {
return {
type,
attrs,
content
};
}
break;
}
case 'extensionFrame':
{
if (content) {
return {
type,
attrs,
content
};
}
break;
}
case 'hardBreak':
{
return {
type
};
}
case 'caption':
{
if (content) {
return {
type,
content
};
}
break;
}
case 'mediaInline':
{
let mediaId = '';
let mediaCollection = [];
if (attrs) {
const {
id,
collection
} = attrs;
mediaId = id;
mediaCollection = collection;
}
if (mediaId && mediaCollection) {
return {
type,
attrs,
marks
};
}
break;
}
case 'media':
{
let mediaId = '';
let mediaType = '';
let mediaCollection = [];
let mediaUrl = '';
if (attrs) {
const {
id,
collection,
type,
url
} = attrs;
mediaId = id;
mediaType = type;
mediaCollection = collection;
mediaUrl = url;
}
if (mediaType === 'external' && !!mediaUrl) {
const mediaAttrs = {
type: mediaType,
url: mediaUrl,
width: attrs.width,
height: attrs.height
};
if (attrs.alt) {
mediaAttrs.alt = attrs.alt;
}
const getMarks = getValidMarks(marks, adfStage);
return getMarks ? {
type,
attrs: mediaAttrs,
marks: getMarks
} : {
type,
attrs: mediaAttrs
};
} else if (mediaId && mediaType) {
const mediaAttrs = {
type: mediaType,
id: mediaId,
collection: mediaCollection
};
if (attrs.width) {
mediaAttrs.width = attrs.width;
}
if (attrs.height) {
mediaAttrs.height = attrs.height;
}
if (attrs.alt) {
mediaAttrs.alt = attrs.alt;
}
const getMarks = getValidMarks(marks, adfStage);
return getMarks ? {
type,
attrs: mediaAttrs,
marks: getMarks
} : {
type,
attrs: mediaAttrs
};
}
break;
}
case 'mediaGroup':
{
if (Array.isArray(content) && !content.some(e => e.type !== 'media')) {
return {
type,
content
};
}
break;
}
case 'mediaSingle':
{
const containsJustMedia = Array.isArray(content) && content.length === 1 && content[0].type === 'media';
const containsMediaAndCaption = Array.isArray(content) && content.length === 2 && content[0].type === 'media' && content[1].type === 'caption';
if (containsJustMedia || containsMediaAndCaption) {
return {
type,
attrs,
content,
marks: getValidMarks(marks, adfStage)
};
}
break;
}
case 'mention':
{
let mentionText = '';
let mentionId;
let mentionAccess;
if (attrs) {
const {
text,
displayName,
id,
accessLevel
} = attrs;
mentionText = text || displayName;
mentionId = id;
mentionAccess = accessLevel;
}
if (!mentionText) {
mentionText = text || '@unknown';
}
if (mentionText && mentionId) {
const mentionNode = {
type,
attrs: {
id: mentionId,
text: mentionText,
accessLevel: ''
}
};
if (mentionAccess) {
mentionNode.attrs.accessLevel = mentionAccess;
}
return mentionNode;
}
break;
}
case 'paragraph':
{
return marks ? {
type,
content: content || [],
marks
} : {
type,
content: content || []
};
}
case 'rule':
{
return {
type
};
}
case 'text':
{
let {
marks
} = node;
if (text) {
return marks ? {
type,
text,
marks: getValidMarks(marks, adfStage)
} : {
type,
text
};
}
break;
}
case 'heading':
{
if (attrs) {
const {
level
} = attrs;
const between = (x, a, b) => x >= a && x <= b;
if (level && between(level, 1, 6)) {
return marks ? {
type,
content,
marks,
attrs: {
level
}
} : {
type,
content,
attrs: {
level
}
};
}
}
break;
}
case 'bulletList':
{
if (content) {
return {
type,
content
};
}
break;
}
case 'orderedList':
{
if (content) {
return {
type,
content,
attrs: {
order: attrs && attrs.order
}
};
}
break;
}
case 'listItem':
{
if (content) {
return {
type,
content: wrapInlineNodes(content)
};
}
break;
}
case 'blockquote':
{
if (content) {
return {
type,
content
};
}
break;
}
case 'panel':
{
if (attrs && content) {
const {
panelType
} = attrs;
if (Object.values(PanelType).includes(panelType)) {
return {
type,
attrs,
content
};
}
}
break;
}
case 'layoutSection':
{
if (content) {
return {
type,
marks,
content
};
}
break;
}
case 'layoutColumn':
{
if (attrs && content) {
if (attrs.width > 0 && attrs.width <= 100) {
return {
type,
content,
attrs
};
}
}
break;
}
case 'decisionList':
{
return {
type,
content,
attrs: {
localId: attrs && attrs.localId || uuid()
}
};
}
case 'decisionItem':
{
return {
type,
content,
attrs: {
localId: attrs && attrs.localId || uuid(),
state: attrs && attrs.state || 'DECIDED'
}
};
}
case 'taskList':
{
return {
type,
content,
attrs: {
localId: attrs && attrs.localId || uuid()
}
};
}
case 'taskItem':
{
return {
type,
content,
attrs: {
localId: attrs && attrs.localId || uuid(),
state: attrs && attrs.state || 'TODO'
}
};
}
case 'table':
{
if (Array.isArray(content) && content.length > 0 && !content.some(e => e.type !== 'tableRow')) {
if (adfStage === 'stage0') {
return {
type,
content,
attrs: {
...attrs,
localId: (attrs === null || attrs === void 0 ? void 0 : attrs.localId) || uuid(),
width: (attrs === null || attrs === void 0 ? void 0 : attrs.width) || null
}
};
}
return {
type,
content,
attrs
};
}
break;
}
case 'tableRow':
{
if (Array.isArray(content) && content.length > 0 && !content.some(e => e.type !== 'tableCell' && e.type !== 'tableHeader')) {
return {
type,
content
};
}
break;
}
case 'tableCell':
case 'tableHeader':
{
if (content) {
const cellAttrs = {};
if (attrs) {
if (attrs.colspan && attrs.colspan > 1) {
cellAttrs.colspan = attrs.colspan;
}
if (attrs.rowspan && attrs.rowspan > 1) {
cellAttrs.rowspan = attrs.rowspan;
}
if (attrs.background) {
cellAttrs.background = attrs.background;
}
if (attrs.colwidth && Array.isArray(attrs.colwidth)) {
cellAttrs.colwidth = attrs.colwidth;
}
}
return {
type,
content: wrapInlineNodes(content),
attrs: attrs ? cellAttrs : undefined
};
}
break;
}
case 'image':
{
if (attrs && attrs.src) {
return {
type,
attrs
};
}
break;
}
case 'placeholder':
{
if (attrs && typeof attrs.text !== 'undefined') {
return {
type,
attrs
};
}
break;
}
case 'expand':
case 'nestedExpand':
{
return {
type,
attrs,
content,
marks
};
}
}
}
return getValidUnknownNode(node);
};
/*
* This method will validate a Mark according to the spec defined here
* https://product-fabric.atlassian.net/wiki/spaces/E/pages/11174043/Document+structure#Documentstructure-Marks
*
* This is also the place to handle backwards compatibility.
*
* If a node is not recognized or is missing required attributes, we should return null
*
*/
export const getValidMark = (mark, adfStage = 'final') => {
const {
attrs,
type
} = mark;
if (type) {
switch (type) {
case 'code':
{
return {
type
};
}
case 'em':
{
return {
type
};
}
case 'link':
{
if (attrs) {
const {
href,
url,
__confluenceMetadata
} = attrs;
let linkHref = href || url;
if (linkHref && linkHref.indexOf(':') === -1 && !RELATIVE_LINK.test(linkHref) && !ANCHOR_LINK.test(linkHref)) {
linkHref = `http://${linkHref}`;
}
const linkAttrs = {
href: linkHref
};
if (__confluenceMetadata) {
linkAttrs.__confluenceMetadata = __confluenceMetadata;
}
if (linkHref && isSafeUrl(linkHref)) {
return {
type,
attrs: linkAttrs
};
}
}
break;
}
case 'strike':
{
return {
type
};
}
case 'strong':
{
return {
type
};
}
case 'subsup':
{
if (attrs && attrs['type']) {
const subSupType = attrs['type'];
if (isSubSupType(subSupType)) {
return {
type,
attrs: {
type: subSupType
}
};
}
}
break;
}
case 'textColor':
{
if (attrs && TEXT_COLOR_PATTERN.test(attrs.color)) {
return {
type,
attrs
};
}
break;
}
case 'underline':
{
return {
type
};
}
case 'annotation':
{
return {
type,
attrs
};
}
case 'border':
{
return {
type,
attrs
};
}
}
}
if (adfStage === 'stage0') {
switch (type) {
case 'confluenceInlineComment':
{
return {
type,
attrs
};
}
case 'dataConsumer':
{
return {
type,
attrs
};
}
case 'fragment':
{
return {
type,
attrs
};
}
case 'border':
{
return {
type,
attrs
};
}
}
}
return null;
};