@beakyn/draft-js-utils
Version:
Draft.js utility belt for handling editor state conversions.
683 lines (598 loc) • 19.3 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var markdownToAst = require('@textlint/markdown-to-ast');
const defaultTagValues = {
src: {
value: ''
},
width: {
value: 'auto'
},
height: {
value: 'auto'
},
alt: {
value: ''
},
href: {
value: ''
},
target: {
value: '_blank'
}
};
const isImgBlock = rawText => rawText.match(/<img /) || rawText.match(/<img>/);
const isIframeBlock = rawText =>
(rawText.match(/<iframe /) || rawText.match(/<iframe>/)) && rawText.match(/<\/iframe>/);
const isLinkBlock = rawText =>
(rawText.match(/<a /) || rawText.match(/<a>/)) && rawText.match(/<\/a>/);
const isHtmlBlock = rawText =>
isIframeBlock(rawText) || isImgBlock(rawText) || isLinkBlock(rawText);
const blockStyleDict = {
'unordered-list-item': '- ',
'header-one': '# ',
'header-two': '## ',
'header-three': '### ',
'header-four': '#### ',
'header-five': '##### ',
'header-six': '###### ',
blockquote: '> '
};
const wrappingBlockStyleDict = {
'code-block': '```'
};
const unwrapAttributes = data => {
const {
src = defaultTagValues.src.value,
width = defaultTagValues.width.value,
height = defaultTagValues.height.value,
alt = defaultTagValues.alt.value,
href = defaultTagValues.href.value
} = data;
return { src, width, height, alt, href };
};
const generateAtomicStyle = {
iframe: (blockText, data, strippedContent) => {
const { src, width, height } = unwrapAttributes(data);
return blockText === ' '
? `${strippedContent}${
data.metadata
? data.metadata.raw
: `<iframe src="${src}" width="${width}" height="${height}></iframe>"`
}`
: `${strippedContent}${blockText}`;
},
img: (blockText, data, strippedContent) => {
const { src, width, height } = unwrapAttributes(data);
return blockText === ' '
? `${strippedContent}${
data.metadata
? data.metadata.raw
: `<img src="${src}" width="${width}" height="${height}">`
}`
: `${strippedContent}${blockText}`;
},
link: (blockText, data, strippedContent) => {
const { href } = unwrapAttributes(data);
return blockText === ' '
? `${strippedContent}${
data.metadata
? data.metadata.raw
: `<a target="_blank" rel=“noopener noreferrer nofollow” href="${href}"></a>`
}`
: `${strippedContent}${blockText}`;
}
};
const defaultMarkdownDict = {
BOLD: '__',
ITALIC: '*'
};
const getBlockStyle = (currentStyle, appliedBlockStyles) => {
if (currentStyle === 'ordered-list-item') {
const counter = appliedBlockStyles.reduce((prev, style) => {
if (style === 'ordered-list-item') {
return prev + 1;
}
return prev;
}, 1);
return `${counter}. `;
}
return blockStyleDict[currentStyle] || '';
};
const applyWrappingBlockStyle = (currentStyle, content) => {
if (currentStyle in wrappingBlockStyleDict) {
const wrappingSymbol = wrappingBlockStyleDict[currentStyle];
return `${wrappingSymbol}\n${content}\n${wrappingSymbol}`;
}
return content;
};
const applyAtomicStyle = (block, entityMap, content) => {
if (block.type !== 'atomic') return content;
// strip the test that was added in the media block
const strippedContent = content.substring(0, content.length - block.text.length);
const key = block.entityRanges[0].key;
const type = entityMap[key].type;
const data = entityMap[key].data;
if (type === 'EMBEDDED_LINK') {
return generateAtomicStyle.iframe(block.text, data, strippedContent);
} else if (type === 'IMAGE') {
return generateAtomicStyle.img(block.text, data, strippedContent);
} else if (type === 'LINK') {
return generateAtomicStyle.link(block.text, data, strippedContent);
} else if (type === 'draft-js-video-plugin-video') {
return `${strippedContent}[[ embed url=${data.url || data.src} ]]`;
}
return `${strippedContent}<img alt="${data.fileName || ''}" src="${data.url || data.src}" />`;
};
const getEntityStart = ({ type, data }) => {
const { url = '', targetOption = '_blank' } = data;
switch (type) {
case 'LINK':
return `<a target="${targetOption}" rel=“noopener noreferrer nofollow” href="${url}">`;
default:
return '';
}
};
const getEntityEnd = ({ type }) => {
switch (type) {
case 'LINK':
return `</a>`;
default:
return '';
}
};
const fixWhitespacesInsideStyle = (text, style) => {
const { symbol } = style;
// Text before style-opening marker (including the marker)
const pre = text.slice(0, style.range.start);
// Text between opening and closing markers
const body = text.slice(style.range.start, style.range.end);
// Trimmed text between markers
const bodyTrimmed = body.trim();
// Text after closing marker
const post = text.slice(style.range.end);
const bodyTrimmedStart = style.range.start + body.indexOf(bodyTrimmed);
// Text between opening marker and trimmed content (leading spaces)
const prefix = text.slice(style.range.start, bodyTrimmedStart);
// Text between the end of trimmed content and closing marker (trailing spaces)
const postfix = text.slice(bodyTrimmedStart + bodyTrimmed.length, style.range.end);
// Temporary text that contains trimmed content wrapped into original pre- and post-texts
const newText = `${pre}${bodyTrimmed}${post}`;
// Insert leading and trailing spaces between pre-/post- contents and their respective markers
return newText.replace(
`${symbol}${bodyTrimmed}${symbol}`,
`${prefix}${symbol}${bodyTrimmed}${symbol}${postfix}`
);
};
const getInlineStyleRangesByLength = inlineStyleRanges =>
[...inlineStyleRanges].sort((a, b) => b.length - a.length);
function draftjsToMd({ blocks, entityMap }, extraMarkdownDict) {
const markdownDict = { ...defaultMarkdownDict, ...extraMarkdownDict };
let returnString = '';
const appliedBlockStyles = [];
// totalOffset is a difference of index position between raw string and enhanced ones
let totalOffset = 0;
blocks.forEach((block, blockIndex) => {
if (blockIndex !== 0) {
returnString += '\n';
totalOffset = 0;
}
// add block style
returnString += getBlockStyle(block.type, appliedBlockStyles);
appliedBlockStyles.push(block.type);
const appliedStyles = [];
returnString += block.text.split('').reduce((text, currentChar, index) => {
let newText = text;
const sortedInlineStyleRanges = getInlineStyleRangesByLength(block.inlineStyleRanges);
// find all styled at this character
const stylesStartAtChar = sortedInlineStyleRanges
.filter(({ offset }) => offset === index)
.filter(({ style }) => markdownDict[style]); // disregard styles not defined in the md dict
// add the symbol to the md string and push the style in the applied styles stack
stylesStartAtChar.forEach(({ style, offset, length }) => {
const symbolLength = markdownDict[style].length;
newText += markdownDict[style];
totalOffset += symbolLength;
appliedStyles.push({
symbol: markdownDict[style],
range: {
start: offset + totalOffset,
end: offset + length + totalOffset
},
end: offset + (length - 1)
});
});
// check for entityRanges starting and add if existing
const entitiesStartAtChar = block.entityRanges.filter(({ offset }) => offset === index);
entitiesStartAtChar.forEach(({ key }) => {
newText += getEntityStart(entityMap[key]);
});
// add the current character to the md string
newText += currentChar;
// check for entityRanges ending and add if existing
const entitiesEndAtChar = block.entityRanges.filter(
({ offset, length }) => offset + length - 1 === index
);
entitiesEndAtChar.forEach(({ key }) => {
newText += getEntityEnd(entityMap[key]);
});
// apply the 'ending' tags for any styles that end in the current position in order (stack)
while (appliedStyles.length !== 0 && appliedStyles[appliedStyles.length - 1].end === index) {
const endingStyle = appliedStyles.pop();
newText += endingStyle.symbol;
newText = fixWhitespacesInsideStyle(newText, endingStyle);
totalOffset += endingStyle.symbol.length;
}
return newText;
}, '');
returnString = applyWrappingBlockStyle(block.type, returnString);
returnString = applyAtomicStyle(block, entityMap, returnString);
});
return returnString;
}
const defaultInlineStyles = {
Strong: {
type: 'BOLD',
symbol: '__'
},
Emphasis: {
type: 'ITALIC',
symbol: '*'
}
};
const defaultBlockStyles = {
List: 'unordered-list-item',
Header1: 'header-one',
Header2: 'header-two',
Header3: 'header-three',
Header4: 'header-four',
Header5: 'header-five',
Header6: 'header-six',
CodeBlock: 'code-block',
BlockQuote: 'blockquote'
};
const getRawLength = children =>
children.reduce((prev, { value }) => prev + (value ? value.length : 0), 0);
const getRawHtmlData = children => {
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(children, 'text/html');
const el = htmlDoc.getElementsByTagName('a')[0];
const { data } = el.childNodes[0];
return { text: data, length: data.length };
};
const getBlockStyleForMd = (node, blockStyles) => {
const style = node.type;
const ordered = node.ordered;
const depth = node.depth;
if (style === 'List' && ordered) {
return 'ordered-list-item';
} else if (style === 'Header') {
return blockStyles[`${style}${depth}`];
} else if (
node.type === 'Paragraph' &&
node.children &&
node.children[0] &&
node.children[0].type === 'Image'
) {
return 'atomic';
} else if (node.type === 'Paragraph' && node.raw && node.raw.match(/^\[\[\s\S+\s.*\S+\s\]\]/)) {
return 'atomic';
} else if (
node.type === 'Html' &&
node.raw &&
(node.raw.match(/<iframe /) || node.raw.match(/<iframe>/)) &&
node.raw.match(/<\/iframe>/)
) {
return 'atomic';
} else if (
node.type === 'Html' &&
node.raw &&
(node.raw.match(/<img /) || node.raw.match(/<img>/))
) {
return 'atomic';
} else if (node.type === 'Html' && node.raw && (node.raw.match(/<a /) || node.raw.match(/<a>/))) {
return 'atomic';
}
return blockStyles[style];
};
const joinCodeBlocks = splitMd => {
const opening = splitMd.indexOf('```');
const closing = splitMd.indexOf('```', opening + 1);
if (opening >= 0 && closing >= 0) {
const codeBlock = splitMd.slice(opening, closing + 1);
const codeBlockJoined = codeBlock.join('\n');
const updatedSplitMarkdown = [
...splitMd.slice(0, opening),
codeBlockJoined,
...splitMd.slice(closing + 1)
];
return joinCodeBlocks(updatedSplitMarkdown);
}
return splitMd;
};
const splitMdBlocks = md => {
const splitMd = md.split('\n');
// Process the split markdown include the
// one syntax where there's an block level opening
// and closing symbol with content in the middle.
const splitMdWithCodeBlocks = joinCodeBlocks(splitMd);
return splitMdWithCodeBlocks;
};
const handleHtmlIframe = (entityMap, entityRanges, attributes, child, text) => {
const {
src = defaultTagValues.src,
width = defaultTagValues.width,
height = defaultTagValues.height
} = attributes;
const entityKey = Object.keys(entityMap).length;
// eslint-disable-next-line no-param-reassign
entityMap[entityKey] = {
type: 'EMBEDDED_LINK',
mutability: 'MUTABLE',
data: {
src: src.value || child.url,
width: width.value,
height: height.value,
metadata: {
raw: child.raw
}
}
};
entityRanges.push({
key: entityKey,
length: 1,
offset: text.length
});
};
const handleHtmlImage = (entityMap, entityRanges, attributes, child, text) => {
const {
src = defaultTagValues.src,
width = defaultTagValues.width,
height = defaultTagValues.height,
alt = defaultTagValues.alt
} = attributes;
const entityKey = Object.keys(entityMap).length;
// eslint-disable-next-line no-param-reassign
entityMap[entityKey] = {
type: 'IMAGE',
mutability: 'MUTABLE',
data: {
url: src.value || child.url,
src: src.value || child.url,
fileName: alt.value,
width: width.value,
height: height.value,
metadata: {
raw: child.raw
}
}
};
entityRanges.push({
key: entityKey,
length: 1,
offset: text.length
});
};
const handleHtmlLink = (entityMap, entityRanges, attributes, child, text) => {
const { href = defaultTagValues.href, target = defaultTagValues.target } = attributes;
const entityKey = Object.keys(entityMap).length;
entityMap[entityKey] = {
type: 'LINK',
mutability: 'MUTABLE',
data: {
url: href.value,
targetOption: target.value
}
};
entityRanges.push({
key: entityKey,
length: getRawHtmlData(child.raw).length,
offset: text.length
});
};
const parseMdLine = (line, existingEntities, extraStyles = {}) => {
const inlineStyles = { ...defaultInlineStyles, ...extraStyles.inlineStyles };
const blockStyles = { ...defaultBlockStyles, ...extraStyles.blockStyles };
const astString = markdownToAst.parse(line);
let text = '';
const inlineStyleRanges = [];
const entityRanges = [];
const entityMap = existingEntities;
const addInlineStyleRange = (offset, length, style) => {
inlineStyleRanges.push({ offset, length, style });
};
const addHtml = child => {
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(child.raw, 'text/html');
let el = null;
if (child.raw.includes('iframe')) {
el = htmlDoc.getElementsByTagName('iframe')[0];
} else if (child.raw.includes('img')) {
el = htmlDoc.getElementsByTagName('img')[0];
} else if (isLinkBlock(child.raw)) {
el = htmlDoc.getElementsByTagName('a')[0];
}
const { attributes } = el;
if (child.raw.includes('iframe')) {
handleHtmlIframe(entityMap, entityRanges, attributes, child, text);
} else if (child.raw.includes('img')) {
handleHtmlImage(entityMap, entityRanges, attributes, child, text);
} else if (isLinkBlock(child.raw)) {
handleHtmlLink(entityMap, entityRanges, attributes, child, text);
}
};
const addLink = ({ url, children }) => {
const entityKey = Object.keys(entityMap).length;
entityMap[entityKey] = {
type: 'LINK',
mutability: 'MUTABLE',
data: {
url
}
};
entityRanges.push({
key: entityKey,
length: getRawLength(children),
offset: text.length
});
};
const addImage = ({ url, alt }) => {
const entityKey = Object.keys(entityMap).length;
entityMap[entityKey] = {
type: 'IMAGE',
mutability: 'IMMUTABLE',
data: {
url,
src: url,
fileName: alt || ''
}
};
entityRanges.push({
key: entityKey,
length: 1,
offset: text.length
});
};
const addVideo = ({ raw }) => {
const string = raw;
// RegEx: [[ embed url=<anything> ]]
const url = string.match(/^\[\[\s(?:embed)\s(?:url=(\S+))\s\]\]/)[1];
const entityKey = Object.keys(entityMap).length;
entityMap[entityKey] = {
type: 'draft-js-video-plugin-video',
mutability: 'IMMUTABLE',
data: {
src: url
}
};
entityRanges.push({
key: entityKey,
length: 1,
offset: text.length
});
};
const parseChildren = (child, style) => {
// RegEx: [[ embed url=<anything> ]]
const videoShortcodeRegEx = /^\[\[\s(?:embed)\s(?:url=(\S+))\s\]\]/;
switch (child.type) {
case 'Html':
if (isIframeBlock(child.raw)) {
addHtml(child);
} else if (isImgBlock(child.raw)) {
addHtml(child);
} else if (isLinkBlock(child.raw)) {
addHtml(child);
}
break;
case 'Link':
addLink(child);
break;
case 'Image':
addImage(child);
break;
case 'Paragraph':
if (isLinkBlock(child.raw)) {
addHtml(child);
} else if (videoShortcodeRegEx.test(child.raw)) {
addVideo(child);
}
break;
default:
}
if (isLinkBlock(child.raw)) {
text = getRawHtmlData(child.raw).text;
} else if (!videoShortcodeRegEx.test(child.raw) && child.children && style) {
const rawLength = getRawLength(child.children);
addInlineStyleRange(text.length, rawLength, style.type);
const newStyle = inlineStyles[child.type];
child.children.forEach(grandChild => {
parseChildren(grandChild, newStyle);
});
} else if (!videoShortcodeRegEx.test(child.raw) && child.children) {
const newStyle = inlineStyles[child.type];
child.children.forEach(grandChild => {
parseChildren(grandChild, newStyle);
});
} else {
if (style) {
addInlineStyleRange(text.length, child.value.length, style.type);
} else if (inlineStyles[child.type]) {
addInlineStyleRange(text.length, child.value.length, inlineStyles[child.type].type);
}
text = `${text}${
child.type === 'Image' || videoShortcodeRegEx.test(child.raw) ? ' ' : child.value
}`;
}
};
astString.children.forEach(child => {
const style = inlineStyles[child.type];
parseChildren(child, style);
});
// add block style if it exists
let blockStyle = 'unstyled';
if (astString.children[0]) {
const style = getBlockStyleForMd(astString.children[0], blockStyles);
if (style) {
blockStyle = style;
}
}
return {
text,
inlineStyleRanges,
entityRanges,
blockStyle,
entityMap
};
};
function mdToDraftjs(mdString, extraStyles) {
const paragraphs = splitMdBlocks(mdString);
const blocks = [];
let entityMap = {};
let isLastAtomic = false;
const addBlankBlock = () => {
blocks.push({
text: '',
type: 'unstyled',
depth: 0,
inlineStyleRanges: [],
entityRanges: []
});
};
if (parseMdLine(paragraphs[0], entityMap, extraStyles).blockStyle === 'atomic') {
addBlankBlock();
}
paragraphs.forEach(paragraph => {
const result = parseMdLine(paragraph, entityMap, extraStyles);
const isCurrentAtomic = result.blockStyle === 'atomic';
if (isLastAtomic && isCurrentAtomic) {
addBlankBlock();
}
blocks.push({
text: isHtmlBlock(result.text) ? ' ' : result.text,
type: result.blockStyle,
depth: 0,
inlineStyleRanges: result.inlineStyleRanges,
entityRanges: result.entityRanges
});
entityMap = result.entityMap;
isLastAtomic = isCurrentAtomic;
});
if (blocks[blocks.length - 1].type === 'atomic') {
addBlankBlock();
}
// add a default value
// not sure why that's needed but Draftjs convertToRaw fails without it
if (Object.keys(entityMap).length === 0) {
entityMap = {
data: '',
mutability: '',
type: ''
};
}
return {
blocks,
entityMap
};
}
exports.draftjsToMd = draftjsToMd;
exports.mdToDraftjs = mdToDraftjs;