import React from 'react'; import { Editor, EditorState, Modifier, ContentState, ContentBlock, convertFromHTML, DefaultDraftBlockRenderMap, DefaultDraftInlineStyle, CompositeDecorator, SelectionState, Entity, } from 'draft-js'; import * as sdk from './index'; import * as emojione from 'emojione'; import {stateToHTML} from 'draft-js-export-html'; import {SelectionRange} from "./autocomplete/Autocompleter"; import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; const MARKDOWN_REGEX = { LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, ITALIC: /([\*_])([\w\s]+?)\1/g, BOLD: /([\*_])\1([\w\s]+?)\1\1/g, HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g, CODE: /`[^`]*`/g, STRIKETHROUGH: /~{2}[^~]*~{2}/g, }; const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); const ZWS_CODE = 8203; const ZWS = String.fromCharCode(ZWS_CODE); // zero width space export function stateToMarkdown(state) { return __stateToMarkdown(state) .replace( ZWS, // draft-js-export-markdown adds these ''); // this is *not* a zero width space, trust me :) } export const contentStateToHTML = (contentState: ContentState) => { return stateToHTML(contentState, { inlineStyles: { UNDERLINE: { element: 'u', }, }, }); }; export function htmlToContentState(html: string): ContentState { const blockArray = convertFromHTML(html).contentBlocks; return ContentState.createFromBlockArray(blockArray); } function unicodeToEmojiUri(str) { let replaceWith, unicode, alt; if ((!emojione.unicodeAlt) || (emojione.sprites)) { // if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames const mappedUnicode = emojione.mapUnicodeToShort(); } str = str.replace(emojione.regUnicode, function(unicodeChar) { if ( (typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap)) ) { // if the unicodeChar doesnt exist just return the entire match return unicodeChar; } else { // Remove variant selector VS16 (explicitly emoji) as it is unnecessary and leads to an incorrect URL below if(unicodeChar.length == 2 && unicodeChar[1] == '\ufe0f') { unicodeChar = unicodeChar[0]; } // get the unicode codepoint from the actual char unicode = emojione.jsEscapeMap[unicodeChar]; return emojione.imagePathSVG+unicode+'.svg'+emojione.cacheBustParam; } }); return str; } /** * Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end) * From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html */ function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) { const text = contentBlock.getText(); let matchArr, start; while ((matchArr = regex.exec(text)) !== null) { start = matchArr.index; callback(start, start + matchArr[0].length); } } // Workaround for https://github.com/facebook/draft-js/issues/414 const emojiDecorator = { strategy: (contentState, contentBlock, callback) => { findWithRegex(EMOJI_REGEX, contentBlock, callback); }, component: (props) => { const uri = unicodeToEmojiUri(props.children[0].props.text); const shortname = emojione.toShort(props.children[0].props.text); const style = { display: 'inline-block', width: '1em', maxHeight: '1em', background: `url(${uri})`, backgroundSize: 'contain', backgroundPosition: 'center center', overflow: 'hidden', }; return ({ props.children }); }, }; /** * Returns a composite decorator which has access to provided scope. */ export function getScopedRTDecorators(scope: any): CompositeDecorator { return [emojiDecorator]; } export function getScopedMDDecorators(scope: any): CompositeDecorator { const markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( (style) => ({ strategy: (contentState, contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); }, component: (props) => ( { props.children } ), })); markdownDecorators.push({ strategy: (contentState, contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback); }, component: (props) => ( { props.children } ), }); // markdownDecorators.push(emojiDecorator); // TODO Consider renabling "syntax highlighting" when we can do it properly return [emojiDecorator]; } /** * Passes rangeToReplace to modifyFn and replaces it in contentState with the result. */ export function modifyText(contentState: ContentState, rangeToReplace: SelectionState, modifyFn: (text: string) => string, inlineStyle, entityKey): ContentState { let getText = (key) => contentState.getBlockForKey(key).getText(), startKey = rangeToReplace.getStartKey(), startOffset = rangeToReplace.getStartOffset(), endKey = rangeToReplace.getEndKey(), endOffset = rangeToReplace.getEndOffset(), text = ""; for (let currentKey = startKey; currentKey && currentKey !== endKey; currentKey = contentState.getKeyAfter(currentKey)) { const blockText = getText(currentKey); text += blockText.substring(startOffset, blockText.length); // from now on, we'll take whole blocks startOffset = 0; } // add remaining part of last block text += getText(endKey).substring(startOffset, endOffset); return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey); } /** * Computes the plaintext offsets of the given SelectionState. * Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc) * Used by autocomplete to show completions when the current selection lies within, or at the edges of a command. */ export function selectionStateToTextOffsets(selectionState: SelectionState, contentBlocks: Array): {start: number, end: number} { let offset = 0, start = 0, end = 0; for (const block of contentBlocks) { if (selectionState.getStartKey() === block.getKey()) { start = offset + selectionState.getStartOffset(); } if (selectionState.getEndKey() === block.getKey()) { end = offset + selectionState.getEndOffset(); break; } offset += block.getLength(); } return { start, end, }; } export function textOffsetsToSelectionState({start, end}: SelectionRange, contentBlocks: Array): SelectionState { let selectionState = SelectionState.createEmpty(); // Subtract block lengths from `start` and `end` until they are less than the current // block length (accounting for the NL at the end of each block). Set them to -1 to // indicate that the corresponding selection state has been determined. for (const block of contentBlocks) { const blockLength = block.getLength(); // -1 indicating that the position start position has been found if (start !== -1) { if (start < blockLength + 1) { selectionState = selectionState.merge({ anchorKey: block.getKey(), anchorOffset: start, }); start = -1; // selection state for the start calculated } else { start -= blockLength + 1; // +1 to account for newline between blocks } } // -1 indicating that the position end position has been found if (end !== -1) { if (end < blockLength + 1) { selectionState = selectionState.merge({ focusKey: block.getKey(), focusOffset: end, }); end = -1; // selection state for the end calculated } else { end -= blockLength + 1; // +1 to account for newline between blocks } } } return selectionState; } // modified version of https://github.com/draft-js-plugins/draft-js-plugins/blob/master/draft-js-emoji-plugin/src/modifiers/attachImmutableEntitiesToEmojis.js export function attachImmutableEntitiesToEmoji(editorState: EditorState): EditorState { const contentState = editorState.getCurrentContent(); const blocks = contentState.getBlockMap(); let newContentState = contentState; blocks.forEach((block) => { const plainText = block.getText(); const addEntityToEmoji = (start, end) => { const existingEntityKey = block.getEntityAt(start); if (existingEntityKey) { // avoid manipulation in case the emoji already has an entity const entity = newContentState.getEntity(existingEntityKey); if (entity && entity.get('type') === 'emoji') { return; } } const selection = SelectionState.createEmpty(block.getKey()) .set('anchorOffset', start) .set('focusOffset', end); const emojiText = plainText.substring(start, end); newContentState = newContentState.createEntity( 'emoji', 'IMMUTABLE', { emojiUnicode: emojiText }, ); const entityKey = newContentState.getLastCreatedEntityKey(); newContentState = Modifier.replaceText( newContentState, selection, emojiText, null, entityKey, ); }; findWithRegex(EMOJI_REGEX, block, addEntityToEmoji); }); if (!newContentState.equals(contentState)) { const oldSelection = editorState.getSelection(); editorState = EditorState.push( editorState, newContentState, 'convert-to-immutable-emojis', ); // this is somewhat of a hack, we're undoing selection changes caused above // it would be better not to make those changes in the first place editorState = EditorState.forceSelection(editorState, oldSelection); } return editorState; } export function hasMultiLineSelection(editorState: EditorState): boolean { const selectionState = editorState.getSelection(); const anchorKey = selectionState.getAnchorKey(); const currentContent = editorState.getCurrentContent(); const currentContentBlock = currentContent.getBlockForKey(anchorKey); const start = selectionState.getStartOffset(); const end = selectionState.getEndOffset(); const selectedText = currentContentBlock.getText().slice(start, end); return selectedText.includes('\n'); }