UNPKG

fulan-editor

Version:

An open source react editor based on draft-Js and ant design, good support HTML, markdown and Draft Raw format.

493 lines (457 loc) 15.7 kB
/* @flow */ import replaceTextWithMeta from './replaceTextWithMeta'; import { CharacterMetadata, ContentBlock, ContentState, Entity, genKey, } from 'draft-js'; import {List, OrderedSet, Repeat, Seq} from 'immutable'; import {BLOCK_TYPE, ENTITY_TYPE, INLINE_STYLE} from '../stateUtils/main'; import {NODE_TYPE_ELEMENT, NODE_TYPE_TEXT} from 'synthetic-dom'; import {colorStyleMap} from "../colorConfig" import type {Set, IndexedSeq} from 'immutable'; import type { Node as SyntheticNode, ElementNode as SyntheticElement, } from 'synthetic-dom'; type DOMNode = SyntheticNode | Node; type DOMElement = SyntheticElement | Element; type CharacterMetaSeq = IndexedSeq<CharacterMetadata>; type Style = string; type StyleSet = Set<Style>; type TextFragment = { text: string; characterMeta: CharacterMetaSeq; }; // A ParsedBlock has two purposes: // 1) to keep data about the block (textFragments, type) // 2) to act as some context for storing parser state as we parse its contents type ParsedBlock = { tagName: string; textFragments: Array<TextFragment>; type: string; // A stack in which the last item represents the styles that will apply // to any text node descendants. styleStack: Array<StyleSet>; entityStack: Array<?Entity>; depth: number; }; type ElementStyles = {[tagName: string]: Style}; type Options = { elementStyles?: ElementStyles; }; const NO_STYLE = OrderedSet(); const NO_ENTITY = null; const EMPTY_BLOCK = new ContentBlock({ key: genKey(), text: '', type: BLOCK_TYPE.UNSTYLED, characterList: List(), depth: 0, }); const LINE_BREAKS = /(\r\n|\r|\n)/g; // We use `\r` because that character is always stripped from source (normalized // to `\n`), so it's safe to assume it will only appear in the text content when // we put it there as a placeholder. const SOFT_BREAK_PLACEHOLDER = '\r'; const ZERO_WIDTH_SPACE = '\u200B'; const DATA_ATTRIBUTE = /^data-([a-z0-9-]+)$/; // Map element attributes to entity data. const ELEM_ATTR_MAP = { a: { href: 'url', rel: 'rel', target: 'target', title: 'title' }, span: { style: 'style', alt: 'alt' }, img: { src: 'src', alt: 'alt' }, video: { src: 'src', alt: 'alt', controls: 'controls' }, audio: { src: 'src', alt: 'alt', controls: 'controls' } }; const getEntityData = (tagName: string, element: DOMElement) => { const data = {}; if (ELEM_ATTR_MAP.hasOwnProperty(tagName)) { const attrMap = ELEM_ATTR_MAP[tagName]; for (let i = 0; i < element.attributes.length; i++) { const {name, value} = element.attributes[i]; if (value != null) { if (attrMap.hasOwnProperty(name)) { const newName = attrMap[name]; data[newName] = value; } else if (DATA_ATTRIBUTE.test(name)) { data[name] = value; } } } } return data; }; // Functions to convert elements to entities. const ELEM_TO_ENTITY = { a(tagName: string, element: DOMElement): ?string { let data = getEntityData(tagName, element); // Don't add `<a>` elements with no href. if (data.url != null) { return Entity.create(ENTITY_TYPE.LINK, 'MUTABLE', data); } }, img(tagName: string, element: DOMElement): ?string { let data = getEntityData(tagName, element); // Don't add `<img>` elements with no src. if (data.src != null) { return Entity.create(ENTITY_TYPE.IMAGE, 'MUTABLE', data); } }, video(tagName: string, element: DOMElement): ?string { let data = getEntityData(tagName, element); // Don't add `<video>` elements with no src. if (data.src != null) { return Entity.create(ENTITY_TYPE.VIDEO, 'MUTABLE', data); } }, audio(tagName: string, element: DOMElement): ?string { let data = getEntityData(tagName, element); // Don't add `<audio>` elements with no src. if (data.src != null) { return Entity.create(ENTITY_TYPE.AUDIO, 'MUTABLE', data); } }, span(tagName: string, element: DOMElement): ?string { let data = getEntityData(tagName, element); // Don't add `<img>` elements with no src. if (data.style != null) { return Entity.create(ENTITY_TYPE.SPAN, 'MUTABLE', data); } }, }; // TODO: Move this out to a module. const INLINE_ELEMENTS = { a: 1, abbr: 1, area: 1, audio: 1, b: 1, bdi: 1, bdo: 1, br: 1, button: 1, canvas: 1, cite: 1, code: 1, command: 1, datalist: 1, del: 1, dfn: 1, em: 1, embed: 1, i: 1, iframe: 1, img: 1, input: 1, ins: 1, kbd: 1, keygen: 1, label: 1, map: 1, mark: 1, meter: 1, noscript: 1, object: 1, output: 1, progress: 1, q: 1, ruby: 1, s: 1, samp: 1, script: 1, select: 1, small: 1, span: 1, strong: 1, sub: 1, sup: 1, textarea: 1, time: 1, u: 1, var: 1, video: 1, wbr: 1, acronym: 1, applet: 1, basefont: 1, big: 1, font: 1, isindex: 1, strike: 1, style: 1, tt: 1, }; // These elements are special because they cannot contain text as a direct // child (some cannot contain childNodes at all). const SPECIAL_ELEMENTS = { area: 1, base: 1, br: 1, col: 1, colgroup: 1, command: 1, dl: 1, embed: 1, head: 1, hgroup: 1, hr: 1, iframe: 1, img: 1, input: 1, keygen: 1, link: 1, meta: 1, ol: 1, optgroup: 1, option: 1, param: 1, script: 1, select: 1, source: 1, style: 1, table: 1, tbody: 1, textarea: 1, tfoot: 1, thead: 1, title: 1, tr: 1, track: 1, ul: 1, wbr: 1, basefont: 1, dialog: 1, dir: 1, isindex: 1, }; // These elements are special because they cannot contain childNodes. const SELF_CLOSING_ELEMENTS = {img: 1,video:2,audio:3}; class BlockGenerator { blockStack: Array<ParsedBlock>; blockList: Array<ParsedBlock>; depth: number; options: Options; constructor(options: Options = {}) { this.options = options; // This represents the hierarchy as we traverse nested elements; for // example [body, ul, li] where we must know li's parent type (ul or ol). this.blockStack = []; // This is a linear list of blocks that will form the output; for example // [p, li, li, blockquote]. this.blockList = []; this.depth = 0; } process(element: DOMElement): Array<ContentBlock> { this.processBlockElement(element); let contentBlocks = []; this.blockList.forEach((block) => { let {text, characterMeta} = concatFragments(block.textFragments); let includeEmptyBlock = false; // If the block contains only a soft break then don't discard the block, // but discard the soft break. if (text === SOFT_BREAK_PLACEHOLDER) { includeEmptyBlock = true; text = ''; } if (block.tagName === 'pre') { ({text, characterMeta} = trimLeadingNewline(text, characterMeta)); } else { ({text, characterMeta} = collapseWhiteSpace(text, characterMeta)); } // Previously we were using a placeholder for soft breaks. Now that we // have collapsed whitespace we can change it back to normal line breaks. text = text.split(SOFT_BREAK_PLACEHOLDER).join('\n').replace(" ",""); // Discard empty blocks (unless otherwise specified). if ((text.length || includeEmptyBlock)&&text!="\n") { contentBlocks.push( new ContentBlock({ key: genKey(), text: text, type: block.type, characterList: characterMeta.toList(), depth: block.depth, data:block.data }) ); } }); if (contentBlocks.length) { return contentBlocks; } else { return [EMPTY_BLOCK]; } } getBlockTypeFromTagName(tagName: string): string { switch (tagName) { case 'li': { let parent = this.blockStack.slice(-1)[0]; return (parent.tagName === 'ol') ? BLOCK_TYPE.ORDERED_LIST_ITEM : BLOCK_TYPE.UNORDERED_LIST_ITEM; } case 'blockquote': { return BLOCK_TYPE.BLOCKQUOTE; } case 'h1': { return BLOCK_TYPE.HEADER_ONE; } case 'h2': { return BLOCK_TYPE.HEADER_TWO; } case 'h3': { return BLOCK_TYPE.HEADER_THREE; } case 'h4': { return BLOCK_TYPE.HEADER_FOUR; } case 'h5': { return BLOCK_TYPE.HEADER_FIVE; } case 'h6': { return BLOCK_TYPE.HEADER_SIX; } case 'pre': { return BLOCK_TYPE.CODE; } // case 'figure': { // return BLOCK_TYPE.ATOMIC; // } default: { return BLOCK_TYPE.UNSTYLED; } } } processBlockElement(element: DOMElement) { let tagName = element.nodeName.toLowerCase(); let type = this.getBlockTypeFromTagName(tagName); let hasDepth = canHaveDepth(type); let allowRender = !SPECIAL_ELEMENTS.hasOwnProperty(tagName); let blockData=new Map(); if (element.style&&element.style.textAlign) { blockData.set("textAlignment",element.style.textAlign); } let block: ParsedBlock = { tagName: tagName, textFragments: [], type: type, styleStack: [NO_STYLE], entityStack: [NO_ENTITY], depth: hasDepth ? this.depth : 0, data:blockData }; if (allowRender) { this.blockList.push(block); if (hasDepth) { this.depth += 1; } } this.blockStack.push(block); if (element.childNodes != null) { Array.from(element.childNodes).forEach(this.processNode, this); } this.blockStack.pop(); if (allowRender && hasDepth) { this.depth -= 1; } } processInlineElement(element: DOMElement) { let tagName = element.nodeName.toLowerCase(); if (tagName === 'br') { this.processText(SOFT_BREAK_PLACEHOLDER); return; } let block = this.blockStack.slice(-1)[0]; let style = block.styleStack.slice(-1)[0]; let entityKey = block.entityStack.slice(-1)[0]; style = addStyleFromTagName(style, tagName, this.options.elementStyles, element); if (ELEM_TO_ENTITY.hasOwnProperty(tagName)) { // If the to-entity function returns nothing, use the existing entity. entityKey = ELEM_TO_ENTITY[tagName](tagName, element) || entityKey; } block.styleStack.push(style); block.entityStack.push(entityKey); if (element.childNodes != null) { Array.from(element.childNodes).forEach(this.processNode, this); } if (SELF_CLOSING_ELEMENTS.hasOwnProperty(tagName)) { this.processText('~'); } block.entityStack.pop(); block.styleStack.pop(); } processTextNode(node: DOMNode) { let text = node.nodeValue; // This is important because we will use \r as a placeholder for a soft break. text = text.replace(LINE_BREAKS, '\n'); // Replace zero-width space (we use it as a placeholder in markdown) with a // soft break. // TODO: The import-markdown package should correctly turn breaks into <br> // elements so we don't need to include this hack. text = text.split(ZERO_WIDTH_SPACE).join(SOFT_BREAK_PLACEHOLDER); this.processText(text); } processText(text: string) { let block = this.blockStack.slice(-1)[0]; let style = block.styleStack.slice(-1)[0]; let entity = block.entityStack.slice(-1)[0]; let charMetadata = CharacterMetadata.create({ style: style, entity: entity, }); let seq: CharacterMetaSeq = Repeat(charMetadata, text.length); block.textFragments.push({ text: text, characterMeta: seq, }); } processNode(node: DOMNode) { if (node.nodeType === NODE_TYPE_ELEMENT) { let element: DOMElement = node; let tagName = element.nodeName.toLowerCase(); if (INLINE_ELEMENTS.hasOwnProperty(tagName)) { this.processInlineElement(element); } else { this.processBlockElement(element); } } else if (node.nodeType === NODE_TYPE_TEXT) { this.processTextNode(node); } } } function trimLeadingNewline(text: string, characterMeta: CharacterMetaSeq): TextFragment { if (text.charAt(0) === '\n') { text = text.slice(1); characterMeta = characterMeta.slice(1); } return {text, characterMeta}; } function trimLeadingSpace(text: string, characterMeta: CharacterMetaSeq): TextFragment { while (text.charAt(0) === ' ') { text = text.slice(1); characterMeta = characterMeta.slice(1); } return {text, characterMeta}; } function trimTrailingSpace(text: string, characterMeta: CharacterMetaSeq): TextFragment { while (text.slice(-1) === ' ') { text = text.slice(0, -1); characterMeta = characterMeta.slice(0, -1); } return {text, characterMeta}; } function collapseWhiteSpace(text: string, characterMeta: CharacterMetaSeq): TextFragment { text = text.replace(/[ \t\n]/g, ' '); ({text, characterMeta} = trimLeadingSpace(text, characterMeta)); ({text, characterMeta} = trimTrailingSpace(text, characterMeta)); let i = text.length; while (i--) { if (text.charAt(i) === ' ' && text.charAt(i - 1) === ' ') { text = text.slice(0, i) + text.slice(i + 1); characterMeta = characterMeta.slice(0, i) .concat(characterMeta.slice(i + 1)); } } // There could still be one space on either side of a softbreak. ({text, characterMeta} = replaceTextWithMeta( {text, characterMeta}, SOFT_BREAK_PLACEHOLDER + ' ', SOFT_BREAK_PLACEHOLDER )); ({text, characterMeta} = replaceTextWithMeta( {text, characterMeta}, ' ' + SOFT_BREAK_PLACEHOLDER, SOFT_BREAK_PLACEHOLDER )); return {text, characterMeta}; } function canHaveDepth(blockType: string): boolean { switch (blockType) { case BLOCK_TYPE.UNORDERED_LIST_ITEM: case BLOCK_TYPE.ORDERED_LIST_ITEM: { return true; } default: { return false; } } } function concatFragments(fragments: Array<TextFragment>): TextFragment { let text = ''; let characterMeta: CharacterMetaSeq = Seq(); fragments.forEach((textFragment: TextFragment) => { text = text + textFragment.text; characterMeta = characterMeta.concat(textFragment.characterMeta); }); return {text, characterMeta}; } function rgbToHex(r, g, b) { return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); } function addStyleFromTagName(styleSet: StyleSet, tagName: string, elementStyles?: ElementStyles, element?:any): StyleSet { // console.log("tagName",tagName,element) switch (tagName) { case 'b': case 'strong': { return styleSet.add(INLINE_STYLE.BOLD); } case 'i': case 'em': { return styleSet.add(INLINE_STYLE.ITALIC); } case 'ins': { return styleSet.add(INLINE_STYLE.UNDERLINE); } case 'code': { return styleSet.add(INLINE_STYLE.CODE); } case 'del': { return styleSet.add(INLINE_STYLE.STRIKETHROUGH); } case 'span': { let savedColor = element.style.color; if (savedColor.lastIndexOf("rgb") > -1) { savedColor = savedColor.substring(savedColor.lastIndexOf("(") + 1, savedColor.length - 1); savedColor = savedColor.split(","); } let savedHex = rgbToHex(parseInt(savedColor[0]), parseInt(savedColor[1]), parseInt(savedColor[2])); let savedKey = ""; Object.keys(colorStyleMap).map(function(key) { if (colorStyleMap[key].color.toLowerCase() == savedHex.toLowerCase()) { savedKey = key; } }) return styleSet.add(savedKey); } default: { // Allow custom styles to be provided. if (elementStyles && elementStyles[tagName]) { return styleSet.add(elementStyles[tagName]); } return styleSet; } } } export default function stateFromElement(element: DOMElement, options?: Options): ContentState { let blocks = new BlockGenerator(options).process(element); return ContentState.createFromBlockArray(blocks); }