import { EditorState, ContentState, Modifier, RichUtils, AtomicBlockUtils, convertToRaw, convertFromRaw } from 'draft-js'; import createStyles from 'draft-js-custom-styles'; import Immutable from 'immutable' import SelectionStateUtil from './selection-state-util'; import { DEFAULT_BLOCK_INLINE_STYLES } from '../custom-style-manager/custom-styles'; import { parseHTML } from '../custom-style-manager/importer'; import htmlToDraft from 'html-to-draftjs'; import upperFirst from 'lodash/upperFirst'; class EditorStateUtil { colorStyler: any constructor() { this.colorStyler = createStyles(['color']).styles.color; } // SAVING & LOADING ----- stateFromHTML(rawHTML) { const parsedHTML = parseHTML(rawHTML); const { contentBlocks, entityMap } = htmlToDraft(parsedHTML); const contentState = ContentState.createFromBlockArray(contentBlocks, entityMap); const editorState = EditorState.createWithContent(contentState); return editorState } loadEditorState(contentString, decorators) { // Will attempt to load JSON string, or HTML if possible let editorState; if (contentString) { try { // Try to parse a JSON obj, else assume HTML and parse that const parsedJSON = JSON.parse(contentString); const JSONContent = convertFromRaw(parsedJSON); editorState = EditorState.createWithContent(JSONContent, decorators); } catch (e) { editorState = this.stateFromHTML(contentString); } } else { editorState = EditorState.createEmpty(decorators); } if (editorState.getCurrentContent().getPlainText() === "") { editorState = this.toggleBlockType(editorState, 'unstyled'); } return editorState; } rawContentString(editorState) { // Returns a JSON-string of a contentState map, for saving to DB return JSON.stringify(convertToRaw(editorState.getCurrentContent())); } // INLINE STYLES ----- currentStyles(editorState) { return editorState.getCurrentInlineStyle().toJS(); } toggleInlineStyle(editorState, styleCode) { const newEditorState = RichUtils.toggleInlineStyle(editorState, styleCode); return newEditorState; } // COLOR STYLES ----- currentColor(editorState) { const stylerColor = this.colorStyler.current(editorState); if (stylerColor) { return stylerColor; } const defaultBlockStyles = DEFAULT_BLOCK_INLINE_STYLES[this.currentBlock(editorState).getType()]; const defaultBlockColor = defaultBlockStyles && ( defaultBlockStyles .filter(style => style.startsWith('CUSTOM_COLOR_'))[0] .slice(13) ) return defaultBlockColor || 'rgba(0,0,0,1)'; } toggleColorStyle(editorState, styleCode) { let newEditorState = editorState; if (newEditorState.getSelection().isCollapsed()) { const customStyleCode = `CUSTOM_COLOR_${styleCode}`; const currentStyles = this.currentStyles(newEditorState); const draftInlineStyles = Immutable.OrderedSet([customStyleCode].concat(currentStyles)); newEditorState = EditorState.setInlineStyleOverride(newEditorState, draftInlineStyles); } else { newEditorState = this.colorStyler.toggle(editorState, styleCode); } return newEditorState; } // BLOCK TYPES ----- currentBlock(editorState) { const startKey = editorState.getSelection().getStartKey(); const currentContent = editorState.getCurrentContent(); return currentContent.getBlockForKey(startKey) } previousBlock(editorState) { const startKey = editorState.getSelection().getStartKey(); const currentContent = editorState.getCurrentContent(); return currentContent.getBlockBefore(startKey); } toggleBlockType(editorState, blockType) { const currentSelection = editorState.getSelection(); let newEditorState = editorState; newEditorState = RichUtils.toggleBlockType(newEditorState, blockType); if (blockType === 'unordered-list-item' || blockType === 'ordered-list-item') { if (!this.currentBlockData(newEditorState, 'depth')) { newEditorState = this.mergeBlockData(newEditorState, { depth: 0 }); } } const defaultInlineStyles = DEFAULT_BLOCK_INLINE_STYLES[blockType]; if (defaultInlineStyles) { newEditorState = this.applyInlineStylesToEntireBlock(newEditorState, defaultInlineStyles); newEditorState = EditorState.forceSelection(newEditorState, currentSelection); if (currentSelection.isCollapsed()) { const draftInlineStyles:any = Immutable.OrderedSet(defaultInlineStyles); newEditorState = EditorState.setInlineStyleOverride(newEditorState, draftInlineStyles); } } return newEditorState; } applyInlineStylesToEntireBlock(editorState, inlineStyles) { const currentBlock = this.currentBlock(editorState); const currentSelection = editorState.getSelection(); const entireBlockSelection = SelectionStateUtil.selectEntireBlock(currentSelection, currentBlock); let newEditorState = editorState; if (inlineStyles.some(inlineStyle => inlineStyle.startsWith('CUSTOM_COLOR_'))) { let pseudoSelectionEditorState = EditorState.acceptSelection(editorState, entireBlockSelection); newEditorState = this.colorStyler.remove(pseudoSelectionEditorState); } let newContentState = newEditorState.getCurrentContent(); inlineStyles.forEach(inlineStyle => { newContentState = Modifier.applyInlineStyle( newContentState, entireBlockSelection, inlineStyle, ); }); newEditorState = EditorState.push( editorState, newContentState, 'change-inline-style' ); return newEditorState; } // METADATA ----- currentBlockData(editorState, key) { const currentBlock = this.currentBlock(editorState); return currentBlock.getData().get(key); } toggleBlockMetaStyle(editorState, styleCode) { const [key, value] = styleCode.split('-'); const data = { [key] : value }; const newEditorState = this.mergeBlockData(editorState, data); return newEditorState; } mergeBlockData(editorState, data) { const newContentState = Modifier.mergeBlockData( editorState.getCurrentContent(), editorState.getSelection(), data ); const newEditorState = EditorState.push( editorState, newContentState, 'change-block-data' ); return newEditorState; } mergeBlockDataByKey(editorState, blockKey, data) { const newContentState = Modifier.mergeBlockData( editorState.getCurrentContent(), SelectionStateUtil.selectBlockByKey(blockKey), data ); const newEditorState = EditorState.push( editorState, newContentState, 'change-block-data' ); return newEditorState; } // ENTITY APPLICATION ----- applyEntity(editorState, type, mutability, data = {}) { const contentState = editorState.getCurrentContent(); const contentStateWithEntity = contentState.createEntity( type, mutability, data ); const entityKey = contentStateWithEntity.getLastCreatedEntityKey(); const newContentState = Modifier.applyEntity( contentStateWithEntity, editorState.getSelection(), entityKey ); const newEditorState = EditorState.push( editorState, newContentState, 'apply-entity' ); return newEditorState; } applyEntityToText(editorState, entityInfo, selectionInfo) { const { blockKey, textRangeStart, textRangeEnd } = selectionInfo; const { type, mutability, data } = entityInfo; const originalSelection = editorState.getSelection(); const textSelection = SelectionStateUtil.selectTextRange(blockKey, textRangeStart, textRangeEnd); const contentState = editorState.getCurrentContent(); const contentStateWithEntity = contentState.createEntity(type, mutability, data); const entityKey = contentStateWithEntity.getLastCreatedEntityKey(); const newContentState = Modifier.applyEntity( contentStateWithEntity, textSelection, entityKey ); const updatedSelection = SelectionStateUtil.updateRange(originalSelection, textRangeEnd, textRangeEnd); let newEditorState = EditorState.push( editorState, newContentState, 'apply-entity' ) newEditorState = EditorState.forceSelection(newEditorState, updatedSelection); return newEditorState; } removeEntity(editorState) { const currentBlock = this.currentBlock(editorState); const contentState = editorState.getCurrentContent(); const selection = editorState.getSelection(); let { startOffset, endOffset } = SelectionStateUtil.getOffsets(selection); const key = currentBlock.getEntityAt(endOffset - 1); const chars = currentBlock.getCharacterList(); ({ startOffset, endOffset } = this.currentEntityRange(key, chars, startOffset, endOffset)); const newSelection = SelectionStateUtil.updateRange(selection, startOffset, endOffset); const newContentState = Modifier.applyEntity( contentState, newSelection, null); const newEditorState = EditorState.push( editorState, newContentState, 'apply-entity' ); return newEditorState; } // ATOMIC BLOCKS ----- applyAtomicBlock(editorState, type, mutability, data = {}) { if (this.currentBlock(editorState).getType() === 'atomic') { return editorState; // Don't overlap atomic blocks } const contentState = editorState.getCurrentContent(); const contentStateWithEntity = contentState.createEntity( type, mutability, data ); const entityKey = contentState.getLastCreatedEntityKey(); const newContentState = Modifier.applyEntity( contentStateWithEntity, editorState.getSelection(), entityKey ); let newEditorState = EditorState.push( editorState, newContentState, 'apply-entity' ); newEditorState = AtomicBlockUtils.insertAtomicBlock( editorState, entityKey, ' ' // <-- Needs some character to apply the entity to ); return newEditorState; } selectAtomicBlock(editorState, blockKey) { const newSelection = SelectionStateUtil.selectBlockByKey(blockKey); const newEditorState = EditorState.acceptSelection(editorState, newSelection); return newEditorState; } removeAtomicBlock(editorState, blockKey) { const newSelection = SelectionStateUtil.selectBlockByKey(blockKey); const contentState = editorState.getCurrentContent(); const newContentState = Modifier.removeRange( contentState, newSelection, 'backward' ); const newEditorState = EditorState.push(editorState, newContentState, 'remove-range'); return newEditorState; } // ENTITY RETRIEVAL ----- getEntityDataByKey(editorState, entityKey) { const contentState = editorState.getCurrentContent(); const entity = contentState.getEntity(entityKey); const entityData = entity.getData(); return entityData; } currentEntityData(editorState, dataKey = null) { const contentState = editorState.getCurrentContent(); const entityKey = contentState.getLastCreatedEntityKey(); const entity = contentState.getEntity(entityKey); const data = entity.getData(); return dataKey ? data[dataKey] : data; } currentEntityRange(entityKey, charList, startOffset, endOffset) { const startChar = startOffset >= 0 ? charList.get(startOffset) : null; // Prevents negative index wrapping around const endChar = charList.get(endOffset); const startHasEntity = !!startChar && startChar.getEntity() === entityKey; const endHasEntity = !!endChar && endChar.getEntity() === entityKey; if (!startHasEntity && !endHasEntity) { return { startOffset, endOffset }; } if (startHasEntity) { startOffset -= 1; } if (endHasEntity) { endOffset += 1; } return this.currentEntityRange(entityKey, charList, startOffset, endOffset); } currentEntityType(editorState) { const currentBlock = this.currentBlock(editorState); const selection = editorState.getSelection(); const { startOffset, endOffset } = SelectionStateUtil.getOffsets(selection); const contentState = editorState.getCurrentContent() let entityType; if (selection.isCollapsed()) { const entityKey = currentBlock.getEntityAt(endOffset - 1); entityType = entityKey && contentState.getEntity(entityKey).getType(); } else { const startEntityKey = currentBlock.getEntityAt(startOffset); const startEntityType = startEntityKey && contentState.getEntity(startEntityKey).getType(); const endEntityKey = currentBlock.getEntityAt(endOffset - 1); const endEntityType = endEntityKey && contentState.getEntity(endEntityKey).getType(); entityType = (startEntityType === endEntityType) ? endEntityType : null; } return entityType; } nextEntityType(editorState) { const selection = editorState.getSelection(); if (!selection.isCollapsed()) { return null; } const contentState = editorState.getCurrentContent(); const currentBlock = this.currentBlock(editorState); const endOffset = selection.getEndOffset(); const nextEntityKey = currentBlock.getEntityAt(endOffset); const nextEntityType = nextEntityKey && contentState.getEntity(nextEntityKey).getType(); return nextEntityType; } mergeEntityData(editorState, entityKey, data) { const contentState = editorState.getCurrentContent(); const newContentState = contentState.mergeEntityData(entityKey, data); const newEditorState = EditorState.push( editorState, newContentState, 'apply-entity' ); return newEditorState; } // PSEUDO STYLES ----- applyPseudoStyle(editorState, styleType, clearSelection = false) { const style = `PSEUDO_${upperFirst(styleType)}`; let newEditorState = this.toggleInlineStyle(editorState, style); if (clearSelection) { newEditorState = this.clearSelection(newEditorState); } return newEditorState; } // HELPERS ----- clearSelection(editorState) { const blockKey = editorState.getSelection().getStartKey() const emptySelection = SelectionStateUtil.createEmpty(blockKey); const newEditorState = EditorState.acceptSelection(editorState, emptySelection); return newEditorState; } getInlineStyleRanges(block) { const ranges = []; const characterFn = () => true; const rangeFn = (start, end) => { ranges.push([start, end]); } block.findStyleRanges(characterFn, rangeFn); return ranges; } splitBlock(editorState) { const newContentState = Modifier.splitBlock( editorState.getCurrentContent(), editorState.getSelection() ); const newEditorState = EditorState.push( editorState, newContentState, 'split-block' ); return newEditorState; } insertText(editorState, text) { const newContentState = Modifier.insertText( editorState.getCurrentContent(), editorState.getSelection(), ' ' ); const newEditorState = EditorState.push( editorState, newContentState, 'insert-characters' ); return newEditorState; } } export default new EditorStateUtil();