diff --git a/webapp/src/components/live-markdown-plugin/block-types/codeBlockStrategy.ts b/webapp/src/components/live-markdown-plugin/block-types/codeBlockStrategy.ts new file mode 100644 index 000000000..6a402c0a9 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/block-types/codeBlockStrategy.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {ContentBlock, ContentState, Modifier, SelectionState} from 'draft-js' + +import {BlockStrategy} from '../pluginStrategy' + +import findRangesWithRegex from '../utils/findRangesWithRegex' + +const createCodeBlockStrategy = (): BlockStrategy => { + const blockType = 'code-block' + const CODE_BLOCK_REGEX = /^```/g + + return { + type: blockType, + className: 'code-block', + mapBlockType: (contentState) => { + // Takes a ContentState and returns a ContentState with code block content + // block type applied + const blockMap = contentState.getBlockMap() + let newContentState = contentState + let codeBlockKeys: string[] = [] + let notCodeBlockKeys: string[] = [] + let tempKeys: string[] = [] + let language: string + + // Find all code blocks + blockMap.forEach((block, blockKey) => { + if (!block || !blockKey) { + return + } + const text = block.getText() + const codeBlockDelimiterRanges = findRangesWithRegex( + text, + CODE_BLOCK_REGEX, + ) + const precededByDelimiter = tempKeys.length > 0 + + // Parse out the language specified after the delimiter for use with the + // draft-js-prism-plugin for syntax highlighting + if (codeBlockDelimiterRanges.length > 0 && !precededByDelimiter) { + language = (text.match(/\w+/g) || [])[0] || 'javascript' + } + + // If we find the opening code block delimiter we must maintain an array + // of all keys for content blocks that might need to be code blocks if we + // later find a closing code block delimiter + if (codeBlockDelimiterRanges.length > 0 || precededByDelimiter) { + tempKeys.push(blockKey) + } else { + notCodeBlockKeys.push(blockKey) + } + + // If we find the closing code block delimiter ``` then store the keys for + // the sandwiched content blocks + if (codeBlockDelimiterRanges.length > 0 && precededByDelimiter) { + codeBlockKeys = codeBlockKeys.concat(tempKeys) + tempKeys = [] + } + }) + + // Loop through keys for blocks that should not have code block type and remove + // code block type if necessary + notCodeBlockKeys = notCodeBlockKeys.concat(tempKeys) + notCodeBlockKeys.forEach((blockKey) => { + if (newContentState.getBlockForKey(blockKey).getType() === blockType) { + newContentState = Modifier.setBlockType( + newContentState, + SelectionState.createEmpty(blockKey), + 'unstyled', + ) + } + }) + + // Loop through found code block keys and apply the block style and language + // metadata to the block + codeBlockKeys.forEach((blockKey, i) => { + // Apply language metadata to block (ignore delimiter blocks) + const isDelimiterBlock = i === 0 || i === codeBlockKeys.length - 1 + const block = newContentState.getBlockForKey(blockKey) + const newBlockMap = newContentState.getBlockMap() + const data = block. + getData(). + merge({language: isDelimiterBlock ? undefined : language}) + const newBlock = block.merge({data}) as ContentBlock + newContentState = newContentState.merge({ + blockMap: newBlockMap.set(blockKey, newBlock), + }) as ContentState + + // Apply block type to block + newContentState = Modifier.setBlockType( + newContentState, + SelectionState.createEmpty(blockKey), + blockType, + ) + }) + + return newContentState + }, + } +} + +export default createCodeBlockStrategy diff --git a/webapp/src/components/live-markdown-plugin/block-types/headingBlockStrategy.ts b/webapp/src/components/live-markdown-plugin/block-types/headingBlockStrategy.ts new file mode 100644 index 000000000..b759bcf5e --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/block-types/headingBlockStrategy.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {Modifier, SelectionState} from 'draft-js' + +import {BlockStrategy} from '../pluginStrategy' + +import findRangesWithRegex from '../utils/findRangesWithRegex' + +const createHeadingBlockStrategy = (): BlockStrategy => { + const HEADING_REGEX = /(^#{1,6})\s(.*)/gm + const HEADING_LEVELS = [ + 'header-one', + 'header-two', + 'header-three', + 'header-four', + 'header-five', + 'header-six', + ] + + return { + type: 'heading', + className: 'heading-block', + mapBlockType: (contentState) => { + // Takes a ContentState and returns a ContentState with heading content block + // type applied + const blockMap = contentState.getBlockMap() + let newContentState = contentState + + // Find all heading blocks + blockMap.forEach((block, blockKey) => { + if (!block || !blockKey) { + return + } + + const text = block.getText() + const headingBlockDelimiterRanges = findRangesWithRegex( + text, + HEADING_REGEX, + ) + let headingLevel = 1 + + // Determine what heading level it should be + if (headingBlockDelimiterRanges.length > 0) { + headingLevel = (text.match(/#/g) || []).length + } + + // Apply the corresponding heading block type + if (headingBlockDelimiterRanges.length > 0) { + newContentState = Modifier.setBlockType( + newContentState, + SelectionState.createEmpty(blockKey), + HEADING_LEVELS[headingLevel - 1], + ) + } else if (HEADING_LEVELS.includes(newContentState.getBlockForKey(blockKey).getType())) { + // Remove any existing heading block type if there shouldn't be one + newContentState = Modifier.setBlockType( + newContentState, + SelectionState.createEmpty(blockKey), + 'unstyled', + ) + } + }) + + return newContentState + }, + } +} + +export default createHeadingBlockStrategy diff --git a/webapp/src/components/live-markdown-plugin/inline-styles/boldStyleStrategy.ts b/webapp/src/components/live-markdown-plugin/inline-styles/boldStyleStrategy.ts new file mode 100644 index 000000000..516a265d9 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/inline-styles/boldStyleStrategy.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {InlineStrategy} from '../pluginStrategy' +import findRangesWithRegex from '../utils/findRangesWithRegex' + +// Bold can be delimited by: **, __, ***, and ___ +const createBoldStyleStrategy = (): InlineStrategy => { + const asteriskDelimitedRegex = + '(\\*\\*\\*)(.+?)(\\*\\*\\*)|(\\*\\*)(.+?)(\\*\\*)(?!\\*)' + const underscoreDelimitedRegex = '(___)(.+?)(___)|(__)(.+?)(__)(?!_)' + const boldRegex = new RegExp( + `${asteriskDelimitedRegex}|${underscoreDelimitedRegex}`, + 'g', + ) + const boldDelimiterRegex = /^(\*\*\*|\*\*|___|__)|(\*\*\*|\*\*|___|__)$/g + + return { + style: 'BOLD', + delimiterStyle: 'BOLD-DELIMITER', + findStyleRanges: (block) => { + // Return an array of arrays containing start and end indices for ranges of + // text that should be bolded + // e.g. [[0,6], [10,20]] + const text = block.getText() + const boldRanges = findRangesWithRegex(text, boldRegex) + return boldRanges + }, + findDelimiterRanges: (block, styleRanges) => { + // Find ranges for delimiters at the beginning/end of styled text ranges + // Returns an array of arrays containing start and end indices for delimiters + const text = block.getText() + let boldDelimiterRanges: number[][] = [] + styleRanges.forEach((styleRange) => { + const delimiterRange = findRangesWithRegex( + text.substring(styleRange[0], styleRange[1] + 1), + boldDelimiterRegex, + ).map((indices) => indices.map((x) => x + styleRange[0])) + boldDelimiterRanges = boldDelimiterRanges.concat(delimiterRange) + }) + return boldDelimiterRanges + }, + delimiterStyles: { + opacity: 0.4, + }, + } +} + +export default createBoldStyleStrategy diff --git a/webapp/src/components/live-markdown-plugin/inline-styles/headingDelimiterStyleStrategy.ts b/webapp/src/components/live-markdown-plugin/inline-styles/headingDelimiterStyleStrategy.ts new file mode 100644 index 000000000..1ed8ba4a3 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/inline-styles/headingDelimiterStyleStrategy.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {InlineStrategy} from '../pluginStrategy' +import findRangesWithRegex from '../utils/findRangesWithRegex' + +const createHeadingDelimiterStyleStrategy = (): InlineStrategy => { + const headingDelimiterRegex = /(^#{1,6})\s/g + + return { + style: 'HEADING-DELIMITER', + findStyleRanges: (block) => { + // Skip the text search if the block isn't a header block + if (block.getType().indexOf('header') < 0) { + return [] + } + + const text = block.getText() + const headingDelimiterRanges = findRangesWithRegex( + text, + headingDelimiterRegex, + ) + return headingDelimiterRanges + }, + styles: { + opacity: 0.4, + }, + } +} + +export default createHeadingDelimiterStyleStrategy diff --git a/webapp/src/components/live-markdown-plugin/inline-styles/inlineCodeStyleStrategy.ts b/webapp/src/components/live-markdown-plugin/inline-styles/inlineCodeStyleStrategy.ts new file mode 100644 index 000000000..9e55e32f5 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/inline-styles/inlineCodeStyleStrategy.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {InlineStrategy} from '../pluginStrategy' +import findRangesWithRegex from '../utils/findRangesWithRegex' + +const createInlineCodeStyleStrategy = (): InlineStrategy => { + const codeRegex = /(`)([^\n\r`]+?)(`)/g + + return { + style: 'INLINE-CODE', + findStyleRanges: (block) => { + // Don't allow inline code inside of code blocks + if (block.getType() === 'code-block') { + return [] + } + + const text = block.getText() + const codeRanges = findRangesWithRegex(text, codeRegex) + return codeRanges + }, + styles: { + fontFamily: 'monospace', + }, + } +} + +export default createInlineCodeStyleStrategy diff --git a/webapp/src/components/live-markdown-plugin/inline-styles/italicStyleStrategy.ts b/webapp/src/components/live-markdown-plugin/inline-styles/italicStyleStrategy.ts new file mode 100644 index 000000000..34e0b51a0 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/inline-styles/italicStyleStrategy.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {InlineStrategy} from '../pluginStrategy' +import findRangesWithRegex from '../utils/findRangesWithRegex' + +const createItalicStyleStrategy = (): InlineStrategy => { + const asteriskDelimitedRegex = '(? { + // Return an array of arrays containing start and end indices for ranges of + // text that should be italicized + // e.g. [[0,6], [10,20]] + const text = block.getText() + const italicRanges = findRangesWithRegex(text, italicRegex) + return italicRanges + }, + findDelimiterRanges: (block, styleRanges) => { + // Find ranges for delimiters at the beginning/end of styled text ranges + // Returns an array of arrays containing start and end indices for delimiters + const text = block.getText() + let italicDelimiterRanges: number[][] = [] + styleRanges.forEach((styleRange) => { + const delimiterRange = findRangesWithRegex( + text.substring(styleRange[0], styleRange[1] + 1), + italicDelimiterRegex, + ).map((indices) => indices.map((x) => x + styleRange[0])) + italicDelimiterRanges = italicDelimiterRanges.concat(delimiterRange) + }) + return italicDelimiterRanges + }, + delimiterStyles: { + opacity: 0.4, + }, + } +} + +export default createItalicStyleStrategy diff --git a/webapp/src/components/live-markdown-plugin/inline-styles/olDelimiterStyleStrategy.ts b/webapp/src/components/live-markdown-plugin/inline-styles/olDelimiterStyleStrategy.ts new file mode 100644 index 000000000..d185b9fab --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/inline-styles/olDelimiterStyleStrategy.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {InlineStrategy} from '../pluginStrategy' +import findRangesWithRegex from '../utils/findRangesWithRegex' + +const createOLDelimiterStyleStrategy = (): InlineStrategy => { + const olDelimiterRegex = /^\d{1,3}\. /g + + return { + style: 'OL-DELIMITER', + findStyleRanges: (block) => { + const text = block.getText() + const olDelimiterRanges = findRangesWithRegex(text, olDelimiterRegex) + return olDelimiterRanges + }, + styles: { + fontWeight: 'bold', + }, + } +} + +export default createOLDelimiterStyleStrategy diff --git a/webapp/src/components/live-markdown-plugin/inline-styles/quoteStyleStrategy.ts b/webapp/src/components/live-markdown-plugin/inline-styles/quoteStyleStrategy.ts new file mode 100644 index 000000000..9b56f3763 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/inline-styles/quoteStyleStrategy.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {InlineStrategy} from '../pluginStrategy' +import findRangesWithRegex from '../utils/findRangesWithRegex' + +const createQuoteStyleStrategy = (): InlineStrategy => { + const quoteRegex = /^> (.*)/g + const quoteDelimiterRegex = /^> /g + + return { + style: 'QUOTE', + delimiterStyle: 'QUOTE-DELIMITER', + findStyleRanges: (block) => { + const text = block.getText() + const quoteRanges = findRangesWithRegex(text, quoteRegex) + return quoteRanges + }, + findDelimiterRanges: (block, styleRanges) => { + const text = block.getText() + let quoteDelimiterRanges: number[][] = [] + styleRanges.forEach((styleRange) => { + const delimiterRange = findRangesWithRegex( + text.substring(styleRange[0], styleRange[1] + 1), + quoteDelimiterRegex, + ).map((indices) => indices.map((x) => x + styleRange[0])) + quoteDelimiterRanges = quoteDelimiterRanges.concat(delimiterRange) + }) + return quoteDelimiterRanges + }, + styles: { + opacity: 0.75, + }, + delimiterStyles: { + opacity: 0.4, + }, + } +} + +export default createQuoteStyleStrategy diff --git a/webapp/src/components/live-markdown-plugin/inline-styles/strikethroughStyleStrategy.ts b/webapp/src/components/live-markdown-plugin/inline-styles/strikethroughStyleStrategy.ts new file mode 100644 index 000000000..eb19a63f1 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/inline-styles/strikethroughStyleStrategy.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {InlineStrategy} from '../pluginStrategy' +import findRangesWithRegex from '../utils/findRangesWithRegex' + +const createStrikethroughStyleStrategy = (): InlineStrategy => { + const strikethroughRegex = /(~~)(.+?)(~~)/g + const strikethroughDelimiterRegex = /^(~~)|(~~)$/g + + return { + style: 'STRIKETHROUGH', + delimiterStyle: 'STRIKETHROUGH-DELIMITER', + findStyleRanges: (block) => { + // Return an array of arrays containing start and end indices for ranges of + // text that should be crossed out + // e.g. [[0,6], [10,20]] + const text = block.getText() + const strikethroughRanges = findRangesWithRegex(text, strikethroughRegex) + return strikethroughRanges + }, + findDelimiterRanges: (block, styleRanges) => { + // Find ranges for delimiters at the beginning/end of styled text ranges + // Returns an array of arrays containing start and end indices for delimiters + const text = block.getText() + let strikethroughDelimiterRanges: number[][] = [] + styleRanges.forEach((styleRange) => { + const delimiterRange = findRangesWithRegex( + text.substring(styleRange[0], styleRange[1] + 1), + strikethroughDelimiterRegex, + ).map((indices) => indices.map((x) => x + styleRange[0])) + strikethroughDelimiterRanges = strikethroughDelimiterRanges.concat( + delimiterRange, + ) + }) + return strikethroughDelimiterRanges + }, + styles: { + textDecoration: 'line-through', + }, + delimiterStyles: { + opacity: 0.4, + textDecoration: 'none', + }, + } +} + +export default createStrikethroughStyleStrategy diff --git a/webapp/src/components/live-markdown-plugin/inline-styles/ulDelimiterStyleStrategy.ts b/webapp/src/components/live-markdown-plugin/inline-styles/ulDelimiterStyleStrategy.ts new file mode 100644 index 000000000..c9b955263 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/inline-styles/ulDelimiterStyleStrategy.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {InlineStrategy} from '../pluginStrategy' +import findRangesWithRegex from '../utils/findRangesWithRegex' + +const createULDelimiterStyleStrategy = (): InlineStrategy => { + const ulDelimiterRegex = /^\* /g + + return { + style: 'UL-DELIMITER', + findStyleRanges: (block) => { + const text = block.getText() + const ulDelimiterRanges = findRangesWithRegex(text, ulDelimiterRegex) + return ulDelimiterRanges + }, + styles: { + fontWeight: 'bold', + }, + } +} + +export default createULDelimiterStyleStrategy diff --git a/webapp/src/components/live-markdown-plugin/liveMarkdownPlugin.ts b/webapp/src/components/live-markdown-plugin/liveMarkdownPlugin.ts new file mode 100644 index 000000000..bb30bbc16 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/liveMarkdownPlugin.ts @@ -0,0 +1,263 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import { + EditorState, + CharacterMetadata, + ContentState, + ContentBlock, + EditorChangeType, + DraftStyleMap, +} from 'draft-js' +import {EditorPlugin} from '@draft-js-plugins/editor' +import {Repeat, List} from 'immutable' + +// Inline style handlers +import createBoldStyleStrategy from './inline-styles/boldStyleStrategy' +import createItalicStyleStrategy from './inline-styles/italicStyleStrategy' +import createStrikethroughStyleStrategy from './inline-styles/strikethroughStyleStrategy' +import createHeadingDelimiterStyleStrategy from './inline-styles/headingDelimiterStyleStrategy' +import createULDelimiterStyleStrategy from './inline-styles/ulDelimiterStyleStrategy' +import createOLDelimiterStyleStrategy from './inline-styles/olDelimiterStyleStrategy' +import createQuoteStyleStrategy from './inline-styles/quoteStyleStrategy' +import createInlineCodeStyleStrategy from './inline-styles/inlineCodeStyleStrategy' + +// Block type handlers +import createCodeBlockStrategy from './block-types/codeBlockStrategy' +import createHeadingBlockStrategy from './block-types/headingBlockStrategy' +import {BlockStrategy, InlineStrategy} from './pluginStrategy' + +export interface LiveMarkdownPluginConfig { + inlineStyleStrategies?: InlineStrategy[], + blockTypeStrategies?: BlockStrategy[], +} + +function createLiveMarkdownPlugin(config: LiveMarkdownPluginConfig = {}): EditorPlugin { + const { + inlineStyleStrategies = [ + createBoldStyleStrategy(), + createItalicStyleStrategy(), + createStrikethroughStyleStrategy(), + createHeadingDelimiterStyleStrategy(), + createULDelimiterStyleStrategy(), + createOLDelimiterStyleStrategy(), + createQuoteStyleStrategy(), + createInlineCodeStyleStrategy(), + ], + blockTypeStrategies = [ + createCodeBlockStrategy(), + createHeadingBlockStrategy(), + ], + } = config + + // Construct the editor style map from our inline style strategies + const customStyleMap: DraftStyleMap = {} + inlineStyleStrategies.forEach((styleStrategy) => { + if (styleStrategy.style && styleStrategy.styles) { + customStyleMap[styleStrategy.style] = styleStrategy.styles + } + if (styleStrategy.delimiterStyle && styleStrategy.delimiterStyles) { + customStyleMap[styleStrategy.delimiterStyle] = + styleStrategy.delimiterStyles + } + }) + + // Construct the block style fn + const blockStyleMap = blockTypeStrategies.reduce((map: Record, blockStrategy) => { + map[blockStrategy.type] = blockStrategy.className + return map + }, {}) + const blockStyleFn = (block: ContentBlock) => { + const blockType = block.getType() + return blockStyleMap[blockType] + } + + return { + + // We must handle the maintenance of block types and inline styles on changes. + // To make sure the code is efficient we only perform maintenance on content + // blocks that have been changed. We only perform maintenance for change types + // that result in actual text changes (ignore cursing through text, etc). + onChange: (editorState) => { + // if (editorState.getLastChangeType() === 'insert-fragment') + // return maintainWholeEditorState(); + return maintainEditorState( + editorState, + blockTypeStrategies, + inlineStyleStrategies, + ) + }, + customStyleMap, + blockStyleFn, + } +} + +// Takes an EditorState and returns a ContentState updated with block types and +// inline styles according to the provided strategies +// Takes a targeted approach that only updates the modified block/blocks +const maintainEditorState = ( + editorState: EditorState, + blockTypeStrategies: BlockStrategy[], + inlineStyleStrategies: InlineStrategy[], +) => { + // Bypass maintenance if text was not changed + const lastChangeType = editorState.getLastChangeType() + const bypassOnChangeTypes = [ + 'adjust-depth', + 'apply-entity', + 'change-block-data', + 'change-block-type', + 'change-inline-style', + 'maintain-markdown', + ] + if (bypassOnChangeTypes.includes(lastChangeType)) { + return editorState + } + + // Maintain block types then inline styles + // Order is important bc we want the inline style strategies to be able to + // look at block type to avoid unnecessary regex searching when possible + const contentState = editorState.getCurrentContent() + let newContentState = maintainBlockTypes(contentState, blockTypeStrategies) + newContentState = maintainInlineStyles( + newContentState, + editorState, + inlineStyleStrategies, + ) + + // Apply the updated content state + let newEditorState = editorState + if (contentState !== newContentState) { + newEditorState = EditorState.push( + editorState, + newContentState, + 'maintain-markdown' as EditorChangeType, + ) + } + newEditorState = EditorState.forceSelection( + newEditorState, + editorState.getSelection(), + ) + + return newEditorState +} + +// Takes a ContentState and returns a ContentState with block types and inline styles +// applied or removed as necessary +const maintainBlockTypes = (contentState: ContentState, blockTypeStrategies: BlockStrategy[]) => { + return blockTypeStrategies.reduce((cs, blockTypeStrategy) => { + return blockTypeStrategy.mapBlockType(cs) + }, contentState) +} + +// Takes a ContentState (and EditorState for getting the selection and change type) +// and returns a ContentState with inline styles applied or removed as necessary +const maintainInlineStyles = ( + contentState: ContentState, + editorState: EditorState, + inlineStyleStrategies: InlineStrategy[], +): ContentState => { + const lastChangeType = editorState.getLastChangeType() + const selection = editorState.getSelection() + const blockKey = selection.getStartKey() + const block = contentState.getBlockForKey(blockKey) + const blockMap = contentState.getBlockMap() + let newBlockMap = blockMap + + // If text has been pasted (potentially modifying/creating multiple blocks) or + // the editor is new we must maintain the styles for all content blocks + if (lastChangeType === 'insert-fragment' || !lastChangeType) { + blockMap.forEach((b, k) => { + if (!b || !k) { + return + } + const newBlock = mapInlineStyles(b, inlineStyleStrategies) as ContentBlock + newBlockMap = newBlockMap.set(k, newBlock) + }) + } else { + const newBlock = mapInlineStyles(block, inlineStyleStrategies) as ContentBlock + newBlockMap = newBlockMap.set(blockKey, newBlock) + } + + // If enter was pressed (or the block was otherwise split) we must maintain + // styles in the previous block as well + if (lastChangeType === 'split-block') { + const newPrevBlock = mapInlineStyles( + contentState.getBlockBefore(blockKey)!, + inlineStyleStrategies, + ) as ContentBlock + newBlockMap = newBlockMap.set( + contentState.getKeyBefore(blockKey), + newPrevBlock, + ) + } + + const newContentState = contentState.merge({ + blockMap: newBlockMap, + }) as ContentState + + return newContentState +} + +// Maps inline styles to the provided ContentBlock's CharacterMetadata list based +// on the plugin's inline style strategies +const mapInlineStyles = (block: ContentBlock, strategies: InlineStrategy[]) => { + // This will be called upon any change that has the potential to effect the styles + // of a content block. + // Find all of the ranges that should have styles applied to them (i.e. all bold, + // italic, or strikethrough delimited ranges of the block). + const blockText = block.getText() + + // Create a list of empty CharacterMetadata to map styles to + // eslint-disable-next-line new-cap + let characterMetadataList = List( + // eslint-disable-next-line new-cap + Repeat(CharacterMetadata.create(), blockText.length), + ) + + // Evaluate block text with each style strategy and apply styles to matching + // ranges of text and delimiters + strategies.forEach((strategy) => { + const styleRanges = strategy.findStyleRanges(block) + const delimiterRanges = strategy.findDelimiterRanges ? strategy.findDelimiterRanges(block, styleRanges) : [] + + characterMetadataList = applyStyleRangesToCharacterMetadata( + strategy.style, + styleRanges, + characterMetadataList, + ) + + characterMetadataList = applyStyleRangesToCharacterMetadata( + strategy.delimiterStyle, + delimiterRanges, + characterMetadataList, + ) + }) + + // Apply the list of CharacterMetadata to the content block + return block.set('characterList', characterMetadataList) +} + +// Applies the provided style to the corresponding ranges of the character metadata +const applyStyleRangesToCharacterMetadata = ( + style: string | undefined, + ranges: number[][], + characterMetadataList: List, +) => { + let styledCharacterMetadataList = characterMetadataList + if (!style) { + return styledCharacterMetadataList + } + + ranges.forEach((range) => { + for (let i = range[0]; i <= range[1]; i++) { + const styled = CharacterMetadata.applyStyle( + characterMetadataList.get(i), + style, + ) + styledCharacterMetadataList = styledCharacterMetadataList.set(i, styled) + } + }) + return styledCharacterMetadataList +} + +export default createLiveMarkdownPlugin diff --git a/webapp/src/components/live-markdown-plugin/pluginStrategy.ts b/webapp/src/components/live-markdown-plugin/pluginStrategy.ts new file mode 100644 index 000000000..41a802efd --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/pluginStrategy.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as React from 'react' +import {ContentBlock, ContentState} from 'draft-js' + +export interface InlineStrategy { + style: string, + findStyleRanges: (text: ContentBlock) => number[][], + findDelimiterRanges?: (text: ContentBlock, styleRanges: number[][]) => number[][], + delimiterStyle?: string, + styles?: React.CSSProperties, + delimiterStyles?: React.CSSProperties, +} + +export interface BlockStrategy { + type: string, + className: string, + mapBlockType: (state: ContentState) => ContentState +} diff --git a/webapp/src/components/live-markdown-plugin/utils/findRangesWithRegex.ts b/webapp/src/components/live-markdown-plugin/utils/findRangesWithRegex.ts new file mode 100644 index 000000000..ea23d5358 --- /dev/null +++ b/webapp/src/components/live-markdown-plugin/utils/findRangesWithRegex.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +const findRangesWithRegex = (text: string, regex: RegExp): number[][] => { + const ranges: number[][] = [] + let matches + + do { + matches = regex.exec(text) + if (matches) { + ranges.push([matches.index, (matches.index + matches[0].length) - 1]) + } + } while (matches) + + return ranges +} + +export default findRangesWithRegex diff --git a/webapp/src/components/markdownEditorInput/markdownEditorInput.tsx b/webapp/src/components/markdownEditorInput/markdownEditorInput.tsx index af82749a6..f362a3c82 100644 --- a/webapp/src/components/markdownEditorInput/markdownEditorInput.tsx +++ b/webapp/src/components/markdownEditorInput/markdownEditorInput.tsx @@ -1,28 +1,25 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, { - ReactElement, - useEffect, - useMemo, - useCallback, - useRef, - useState, -} from 'react' -import {getDefaultKeyBinding, EditorState, ContentState, DraftHandleValue} from 'draft-js' import Editor from '@draft-js-plugins/editor' +import createEmojiPlugin from '@draft-js-plugins/emoji' +import '@draft-js-plugins/emoji/lib/plugin.css' import createMentionPlugin, { defaultSuggestionsFilter, MentionData, } from '@draft-js-plugins/mention' import '@draft-js-plugins/mention/lib/plugin.css' -import './markdownEditorInput.scss' +import {ContentState, DraftHandleValue, EditorState, getDefaultKeyBinding} from 'draft-js' +import React, { + ReactElement, useCallback, useEffect, + useMemo, useRef, + useState, +} from 'react' -import createEmojiPlugin from '@draft-js-plugins/emoji' -import '@draft-js-plugins/emoji/lib/plugin.css' - -import {getWorkspaceUsersList} from '../../store/users' import {useAppSelector} from '../../store/hooks' +import {getWorkspaceUsersList} from '../../store/users' import {IUser} from '../../user' +import createLiveMarkdownPlugin from '../live-markdown-plugin/liveMarkdownPlugin' +import './markdownEditorInput.scss' import Entry from './entryComponent/entryComponent' @@ -60,6 +57,7 @@ const MarkdownEditorInput = (props: Props): ReactElement => { const {MentionSuggestions, plugins, EmojiSuggestions} = useMemo(() => { const mentionPlugin = createMentionPlugin({mentionPrefix: '@'}) const emojiPlugin = createEmojiPlugin() + const markdownPlugin = createLiveMarkdownPlugin() // eslint-disable-next-line no-shadow const {EmojiSuggestions} = emojiPlugin @@ -69,6 +67,7 @@ const MarkdownEditorInput = (props: Props): ReactElement => { const plugins = [ mentionPlugin, emojiPlugin, + markdownPlugin, ] return {plugins, MentionSuggestions, EmojiSuggestions} }, [])