@atlaskit/editor-core
Version:
A package contains Atlassian editor core functionality
391 lines (338 loc) • 13.8 kB
text/typescript
import {
Fragment,
Node as PMNode,
MediaAttributes,
MediaType,
Schema
} from '../../';
import parseCxhtml from './parse-cxhtml';
import { AC_XMLNS, default as encodeCxhtml } from './encode-cxhtml';
import {
findTraversalPath,
getNodeName,
addMarks,
getAcName,
getAcParameter,
getAcTagContent,
createCodeFragment,
getAcTagNode,
getMacroAttribute,
getMacroParameters,
hasClass,
marksFromStyle,
getContent,
} from './utils';
import {
blockquoteContentWrapper,
listContentWrapper,
listItemContentWrapper,
ensureInline,
docContentWrapper,
} from './content-wrapper';
const convertedNodes = new WeakMap<Node, Fragment | PMNode>();
// This reverted mapping is used to map Unsupported Node back to it's original cxhtml
const convertedNodesReverted = new WeakMap<Fragment | PMNode, Node>();
export default function(cxhtml: string, schema: Schema<any, any>) {
const dom = parseCxhtml(cxhtml).querySelector('body')!;
return schema.nodes.doc.createChecked({}, parseDomNode(schema, dom));
}
function parseDomNode(schema: Schema<any, any>, dom: Element): PMNode {
const nodes = findTraversalPath(Array.prototype.slice.call(dom.childNodes, 0));
// Process through nodes in reverse (so deepest child elements are first).
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
const content = getContent(node, convertedNodes);
const candidate = converter(schema, content, node);
if (typeof candidate !== 'undefined' && candidate !== null) {
convertedNodes.set(node, candidate);
convertedNodesReverted.set(candidate, node);
}
}
const content = getContent(dom, convertedNodes);
const compatibleContent = content.childCount > 0
// Dangling inline nodes can't be directly inserted into a document, so
// we attempt to wrap in a paragraph.
? schema.nodes.doc.validContent(content)
? content
: docContentWrapper(schema, content, convertedNodesReverted)
// The document must have at least one block element.
: schema.nodes.paragraph.createChecked({});
return compatibleContent;
}
function converter(schema: Schema<any, any>, content: Fragment, node: Node): Fragment | PMNode | null | undefined {
// text
if (node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE) {
const text = node.textContent;
return text ? schema.text(text) : null;
}
// All unsupported content is wrapped in an `unsupportedInline` node. Wrapping
// `unsupportedInline` inside `paragraph` where appropriate is handled when
// the content is inserted into a parent.
const unsupportedInline = schema.nodes.confluenceUnsupportedInline.create({ cxhtml: encodeCxhtml(node) });
// marks and nodes
if (node instanceof Element) {
const tag = getNodeName(node);
switch (tag) {
// Marks
case 'DEL':
case 'S':
return content ? addMarks(content, [schema.marks.strike.create()]) : null;
case 'B':
case 'STRONG':
return content ? addMarks(content, [schema.marks.strong.create()]) : null;
case 'I':
case 'EM':
return content ? addMarks(content, [schema.marks.em.create()]) : null;
case 'CODE':
return content ? addMarks(content, [schema.marks.code.create()]) : null;
case 'SUB':
case 'SUP':
const type = tag === 'SUB' ? 'sub' : 'sup';
return content ? addMarks(content, [schema.marks.subsup.create({ type })]) : null;
case 'U':
return content ? addMarks(content, [schema.marks.underline.create()]) : null;
case 'A':
return content ? addMarks(content, [schema.marks.link.create({ href: node.getAttribute('href') })]) : null;
// Nodes
case 'BLOCKQUOTE':
return schema.nodes.blockquote.createChecked({},
schema.nodes.blockquote.validContent(content)
? content
: blockquoteContentWrapper(schema, content, convertedNodesReverted)
);
case 'SPAN':
return addMarks(content, marksFromStyle(schema, (node as HTMLSpanElement).style));
case 'H1':
case 'H2':
case 'H3':
case 'H4':
case 'H5':
case 'H6':
const level = Number(tag.charAt(1));
return schema.nodes.heading.createChecked({ level },
schema.nodes.heading.validContent(content)
? content
: ensureInline(schema, content, convertedNodesReverted)
);
case 'BR':
return schema.nodes.hardBreak.createChecked();
case 'HR':
return schema.nodes.rule.createChecked();
case 'UL':
return schema.nodes.bulletList.createChecked({},
schema.nodes.bulletList.validContent(content)
? content
: listContentWrapper(schema, content, convertedNodesReverted)
);
case 'OL':
return schema.nodes.orderedList.createChecked({},
schema.nodes.orderedList.validContent(content)
? content
: listContentWrapper(schema, content, convertedNodesReverted)
);
case 'LI':
return schema.nodes.listItem.createChecked({},
schema.nodes.listItem.validContent(content)
? content
: listItemContentWrapper(schema, content, convertedNodesReverted)
);
case 'P':
let output: Fragment = Fragment.from([]);
let textNodes: PMNode[] = [];
let mediaNodes: PMNode[] = [];
if (!node.childNodes.length) {
return schema.nodes.paragraph.createChecked({}, content);
}
content.forEach((childNode, offset) => {
if (childNode.type === schema.nodes.media) {
// if there were text nodes before this node
// combine them into one paragraph and empty the list
if (textNodes.length) {
const paragraph = schema.nodes.paragraph.createChecked({}, textNodes);
output = output.addToEnd(paragraph);
textNodes = [];
}
mediaNodes.push(childNode);
} else {
// if there were media nodes before this node
// combine them into one mediaGroup and empty the list
if (mediaNodes.length) {
const mediaGroup = schema.nodes.mediaGroup.createChecked({}, mediaNodes);
output = output.addToEnd(mediaGroup);
mediaNodes = [];
}
textNodes.push(childNode);
}
});
// combine remaining text nodes
if (textNodes.length) {
const paragraph = schema.nodes.paragraph.createChecked({}, ensureInline(schema, Fragment.fromArray(textNodes), convertedNodesReverted));
output = output.addToEnd(paragraph);
}
// combine remaining media nodes
if (mediaNodes.length) {
const mediaGroup = schema.nodes.mediaGroup.createChecked({}, mediaNodes);
output = output.addToEnd(mediaGroup);
}
return output;
case 'AC:STRUCTURED-MACRO':
return convertConfluenceMacro(schema, node) || unsupportedInline;
case 'FAB:LINK':
if (
node.firstChild &&
node.firstChild instanceof Element &&
getNodeName(node.firstChild) === 'FAB:MENTION'
) {
const cdata = node.firstChild.firstChild!;
return schema.nodes.mention.create({
id: node.firstChild.getAttribute('atlassian-id'),
text: cdata!.nodeValue,
});
}
break;
case 'FAB:MENTION':
const cdata = node.firstChild!;
return schema.nodes.mention.create({
id: node.getAttribute('atlassian-id'),
text: cdata!.nodeValue,
});
case 'FAB:MEDIA':
const mediaAttrs: MediaAttributes = {
id: node.getAttribute('media-id') || '',
type: (node.getAttribute('media-type') || 'file') as MediaType,
collection: node.getAttribute('media-collection') || '',
};
if (node.hasAttribute('file-name')) {
mediaAttrs.__fileName = node.getAttribute('file-name')!;
}
if (node.hasAttribute('file-size')) {
mediaAttrs.__fileSize = parseInt(node.getAttribute('file-size')!, 10);
}
if (node.hasAttribute('file-mime-type')) {
mediaAttrs.__fileMimeType = node.getAttribute('file-mime-type')!;
}
return schema.nodes.media.create(mediaAttrs);
case 'PRE':
return schema.nodes.codeBlock.create({ language: null }, schema.text(node.textContent || ''));
case 'TABLE':
if (hasClass(node, 'wysiwyg-macro')) {
return convertWYSIWYGMacro(schema, node) || unsupportedInline;
} else if (hasClass(node, 'confluenceTable')) {
return convertTable(schema, node);
}
return unsupportedInline;
case 'DIV':
if (hasClass(node, 'codeHeader')) {
const codeHeader = schema.text(node.textContent || '', [ schema.marks.strong.create() ]);
return schema.nodes.heading.createChecked({ level: 5 }, Fragment.from( codeHeader ));
}
else if (node.querySelector('.syntaxhighlighter')) {
const codeblockNode = node.querySelector('.syntaxhighlighter');
return convertCodeFromView(schema, codeblockNode as Element) || unsupportedInline;
}
else if (hasClass(node, 'preformatted')) {
return convertNoFormatFromView(schema, node) || unsupportedInline;
}
return unsupportedInline;
}
}
return unsupportedInline;
}
function convertConfluenceMacro(schema: Schema<any, any>, node: Element): Fragment | PMNode | null | undefined {
const name = getAcName(node);
switch (name) {
case 'CODE':
const language = getAcParameter(node, 'language');
const title = getAcParameter(node, 'title');
const codeContent = getAcTagContent(node, 'AC:PLAIN-TEXT-BODY') || ' ';
return createCodeFragment(schema, codeContent, language, title);
case 'NOFORMAT': {
const codeContent = getAcTagContent(node, 'AC:PLAIN-TEXT-BODY') || ' ';
return schema.nodes.codeBlock.create({ language: null }, schema.text(codeContent));
}
case 'WARNING':
case 'INFO':
case 'NOTE':
case 'TIP':
const panelTitle = getAcParameter(node, 'title');
const panelRichTextBody = getAcTagNode(node, 'AC:RICH-TEXT-BODY') || '';
let panelBody: any[] = [];
if (panelTitle) {
panelBody.push(
schema.nodes.heading.create({ level: 3 }, schema.text(panelTitle))
);
}
if (panelRichTextBody) {
const pmNode = parseDomNode(schema, panelRichTextBody);
panelBody = panelBody.concat(pmNode.content);
} else {
panelBody.push(schema.nodes.paragraph.create({}));
}
return schema.nodes.panel.create({ panelType: name.toLowerCase() }, panelBody);
case 'JIRA':
const schemaVersion = node.getAttributeNS(AC_XMLNS, 'schema-version');
const macroId = node.getAttributeNS(AC_XMLNS, 'macro-id');
const server = getAcParameter(node, 'server');
const serverId = getAcParameter(node, 'serverId');
const issueKey = getAcParameter(node, 'key');
// if this is an issue list, render it as unsupported node
// @see https://product-fabric.atlassian.net/browse/ED-1193?focusedCommentId=26672&page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel#comment-26672
if (!issueKey) {
return schema.nodes.confluenceUnsupportedInline.create({ cxhtml: encodeCxhtml(node) });
}
return schema.nodes.confluenceJiraIssue.create({
issueKey,
macroId,
schemaVersion,
server,
serverId,
});
}
return null;
}
function convertWYSIWYGMacro (schema: Schema<any, any>, node: Element): Fragment | PMNode | null | undefined {
const name = getMacroAttribute(node, 'name').toUpperCase();
switch (name) {
case 'CODE':
case 'NOFORMAT':
const codeContent = node.querySelector('pre')!.textContent || ' ';
const { language, title } = getMacroParameters(node);
return createCodeFragment(schema, codeContent, language, title);
}
return null;
}
function convertCodeFromView (schema: Schema<any, any>, node: Element): Fragment | PMNode | null | undefined {
const container = node.querySelector('.container');
let content = '';
if (container) {
const { childNodes } = container;
for (let i = 0, len = childNodes.length; i < len; i++) {
content += childNodes[i].textContent + (i === len - 1 ? '' : '\n');
}
}
let language;
if (node.className) {
language = (node.className.match(/\w+$/) || [''])[0];
}
return createCodeFragment(schema, content, language);
}
function convertNoFormatFromView (schema: Schema<any ,any>, node: Element): Fragment | PMNode | null | undefined {
const codeContent = node.querySelector('pre')!.textContent || ' ';
return createCodeFragment(schema, codeContent);
}
function convertTable (schema: Schema<any, any>, node: Element) {
const { table, tableRow, tableCell, tableHeader } = schema.nodes;
const rowNodes: PMNode[] = [];
const rows = node.querySelectorAll('tr');
for (let i = 0, rowsCount = rows.length; i < rowsCount; i ++) {
const cellNodes: PMNode[] = [];
const cols = rows[i].querySelectorAll('td,th');
for (let j = 0, colsCount = cols.length; j < colsCount; j ++) {
const cell = cols[j].nodeName === 'td' ? tableCell : tableHeader;
const pmNode = parseDomNode(schema, cols[j]);
cellNodes.push(cell.createChecked(null, pmNode));
}
rowNodes.push(tableRow.create(null, Fragment.from(cellNodes)));
}
return table.create(null, Fragment.from(rowNodes));
}