UNPKG

@atlaskit/editor-core

Version:

A package contains Atlassian editor core functionality

614 lines (560 loc) • 14.2 kB
import { Mark as PMMark, MarkSpec, NodeSpec, Schema } from '../prosemirror'; import { uuid } from '../plugins/utils'; import { isSafeUrl } from './utils'; export interface Doc { version: 1; type: 'doc'; content: Node[]; } export interface Node { type: string; attrs?: any; content?: Node[]; marks?: Mark[]; text?: string; } export interface Mark { type: string; attrs?: any; } export interface MarkSimple { type: { name: string }; attrs?: any; } import { defaultSchema } from '../schema'; /* * 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 = [ 'link', 'em', 'strong', 'strike', 'subsup', 'underline', 'code', ]; export const isSubSupType = (type: string): type is 'sub' | 'sup' => { return type === 'sub' || type === 'sup'; }; /* * Sorts mark by the predefined order above */ export const getMarksByOrder = (marks: PMMark[] ) => { 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: PMMark | null, otherMark: PMMark | null) => { if (!mark || !otherMark) { return false; } return mark.eq(otherMark); }; export const getValidDocument = (doc: Doc, schema: Schema<NodeSpec, MarkSpec> = defaultSchema): Doc | null => { const node = getValidNode(doc as Node, schema); if (node.type === 'doc') { return node as Doc; } return null; }; export const getValidContent = (content: Node[], schema: Schema<NodeSpec, MarkSpec> = defaultSchema): Node[] => { return content.map(node => getValidNode(node, schema)); }; const TEXT_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; const flattenUnknownBlockTree = (node: Node, schema: Schema<NodeSpec, MarkSpec> = defaultSchema): Node[] => { const output: Node[] = []; 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: ' ' } as Node); } else { output.push({ type: 'hardBreak' } as Node); } } if (isLeafNode) { output.push(getValidNode(childNode, schema)); } else { output.push(...flattenUnknownBlockTree(childNode, schema)); } isPrevLeafNode = isLeafNode; } return output; }; // null is Object, also maybe check obj.constructor == Object if we want to skip Class const isValidObject = obj => obj !== null && typeof obj === 'object'; const isValidString = str => typeof str === 'string'; const keysLen = obj => Object.keys(obj).length; const isValidIcon = icon => isValidObject(icon) && keysLen(icon) === 2 && isValidString(icon.url) && isValidString(icon.label); const isValidUser = user => { const len = keysLen(user); return isValidObject(user) && len <= 2 && isValidIcon(user.icon) && ( len === 1 || isValidString(user.id) ); }; /** * Sanitize unknown node tree * * @see https://product-fabric.atlassian.net/wiki/spaces/E/pages/11174043/Document+structure#Documentstructure-ImplementationdetailsforHCNGwebrenderer */ export const getValidUnknownNode = (node: Node): Node => { const { attrs = {}, content, text, type, } = node; if (!content || !content.length) { const unknownInlineNode: Node = { type: 'text', text: text || attrs.text || `[${type}]`, }; if (attrs.textUrl) { unknownInlineNode.marks = [{ type: 'link', attrs: { href: attrs.textUrl, }, } as Mark]; } 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), }; }; /* * 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: Node, schema: Schema<NodeSpec, MarkSpec> = defaultSchema): Node => { const { attrs, marks, text, type } = originalNode; let { content } = originalNode; const node: Node = { attrs, marks, text, type }; if (content) { node.content = content = getValidContent(content, schema); } // 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 'applicationCard': { if (!attrs) { break; } const { text, link, background, preview, title, description, details, context } = attrs; if (!isValidString(text) || !isValidObject(title) || !title.text) { break; } // title can contain at most two keys (text, user) const titleKeys = Object.keys(title); if (titleKeys.length > 2) { break; } if (titleKeys.length === 2 && !title.user) { break; } if (title.user && !isValidUser(title.user)) { break; } if ( (link && !link.url) || (background && !background.url) || (preview && !preview.url) || (description && !description.text)) { break; } if (context && !isValidString(context.text)) { break; } if (context && !isValidIcon(context.icon)) { break; } if (details && !Array.isArray(details)) { break; } if (details && details.some(meta => { const { badge, lozenge, users } = meta; if (badge && !badge.value) { return true; } if (lozenge && !lozenge.text) { return true; } if (users && !Array.isArray(users)) { return true; } if (users && !users.every(isValidUser)) { return true; } })) { break; } return { type, text, attrs }; } case 'doc': { const { version } = originalNode as Doc; if (version && content && content.length) { return { type, content }; } break; } case 'codeBlock': { if (attrs && attrs.language !== undefined) { return { type, attrs, content }; } break; } case 'emoji': { if (attrs && attrs.shortName) { return { type, attrs }; } break; } case 'hardBreak': { return { type }; } case 'media': { let mediaId = ''; let mediaType = ''; let mediaCollection = []; if (attrs) { const { id, collection, type } = attrs; mediaId = id; mediaType = type; mediaCollection = collection; } if (mediaId && mediaType) { return { type, attrs: { type: mediaType, id: mediaId, collection: mediaCollection } }; } break; } case 'mediaGroup': { if (Array.isArray(content) && !content.some(e => e.type !== 'media')) { return { type, content }; } 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 } }; if (mentionAccess) { mentionNode.attrs['accessLevel'] = mentionAccess; } return mentionNode; } break; } case 'paragraph': { if (content) { return { type, content }; } break; } case 'rule': { return { type, }; } case 'text': { let { marks } = node; if (text) { if (marks) { marks = marks.reduce((acc, mark ) => { const validMark = getValidMark(mark); if (validMark) { acc.push(validMark); } return acc; }, [] as Mark[]); } return marks ? { type, text, marks: marks } : { type, text }; } break; } case 'heading': { if (attrs && content) { const { level } = attrs; const between = (x, a, b) => x >= a && x <= b; if (level && between(level, 1, 6)) { return { 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, }; } break; } case 'blockquote': { if (content) { return { type, content, }; } break; } case 'panel': { const types = ['info', 'note', 'tip', 'warning']; if (attrs && content) { const { panelType } = attrs; if (types.indexOf(panelType) > -1) { return { type, attrs: { panelType }, content, }; } } 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')) { return { type, content }; } 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': { if (content) { return { type, content }; } break; } case 'tableHeader': { if (content) { return { type, content }; } break; } } } 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: Mark): Mark | null => { const { attrs, type } = mark; if (type) { switch (type) { case 'code': { return { type, }; } case 'em': { return { type, }; } case 'link': { if (attrs) { const { href, url } = attrs; let linkHref = href || url; if (linkHref.indexOf(':') === -1) { linkHref = `http://${linkHref}`; } if (linkHref && isSafeUrl(linkHref)) { return { type, attrs: { href: linkHref } }; } } 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, }; } } } return null; };