src/model/modifier/exploration/NestedRichTextEditorUtil.js (449 lines of code) (raw):

/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format * @flow * @emails oncall+draft_js * * This is unstable and not part of the public API and should not be used by * production systems. This file may be update/removed without notice. */ import type {BlockMap} from 'BlockMap'; import type ContentState from 'ContentState'; import type {DraftBlockType} from 'DraftBlockType'; import type {DraftEditorCommand} from 'DraftEditorCommand'; import type {DataObjectForLink, RichTextUtils} from 'RichTextUtils'; import type SelectionState from 'SelectionState'; import type URI from 'URI'; const ContentBlockNode = require('ContentBlockNode'); const DraftModifier = require('DraftModifier'); const DraftTreeOperations = require('DraftTreeOperations'); const EditorState = require('EditorState'); const RichTextEditorUtil = require('RichTextEditorUtil'); const adjustBlockDepthForContentState = require('adjustBlockDepthForContentState'); const generateRandomKey = require('generateRandomKey'); const invariant = require('invariant'); // Eventually we could allow to control this list by either allowing user configuration // and/or a schema in conjunction to DraftBlockRenderMap const NESTING_DISABLED_TYPES = ['code-block', 'atomic']; const NestedRichTextEditorUtil: RichTextUtils = { handleKeyCommand: ( editorState: EditorState, command: DraftEditorCommand | string, ): ?EditorState => { switch (command) { case 'bold': return NestedRichTextEditorUtil.toggleInlineStyle(editorState, 'BOLD'); case 'italic': return NestedRichTextEditorUtil.toggleInlineStyle( editorState, 'ITALIC', ); case 'underline': return NestedRichTextEditorUtil.toggleInlineStyle( editorState, 'UNDERLINE', ); case 'code': return NestedRichTextEditorUtil.toggleCode(editorState); case 'backspace': case 'backspace-word': case 'backspace-to-start-of-line': return NestedRichTextEditorUtil.onBackspace(editorState); case 'delete': case 'delete-word': case 'delete-to-end-of-block': return NestedRichTextEditorUtil.onDelete(editorState); default: // they may have custom editor commands; ignore those return null; } }, onDelete: (editorState: EditorState): ?EditorState => { const selection = editorState.getSelection(); if (!selection.isCollapsed()) { return null; } const content = editorState.getCurrentContent(); const startKey = selection.getStartKey(); const block = content.getBlockForKey(startKey); const length = block.getLength(); // The cursor is somewhere within the text. Behave normally. if (selection.getStartOffset() < length) { return null; } const blockAfter = content.getBlockAfter(startKey); if (!blockAfter || blockAfter.getType() !== 'atomic') { return null; } const atomicBlockTarget = selection.merge({ focusKey: blockAfter.getKey(), focusOffset: blockAfter.getLength(), }); const withoutAtomicBlock = DraftModifier.removeRange( content, atomicBlockTarget, 'forward', ); if (withoutAtomicBlock !== content) { return EditorState.push(editorState, withoutAtomicBlock, 'remove-range'); } return null; }, /** * Ensures that if on the beginning of unstyled block and first child of * a nested parent we add its text to the neareast previous leaf node */ onBackspace: (editorState: EditorState): ?EditorState => { const selection = editorState.getSelection(); const content = editorState.getCurrentContent(); const currentBlock = content.getBlockForKey(selection.getStartKey()); const previousBlockKey = currentBlock.getPrevSiblingKey(); if ( !selection.isCollapsed() || selection.getAnchorOffset() || selection.getFocusOffset() || (currentBlock.getType() === 'unstyled' && previousBlockKey && content.getBlockForKey(previousBlockKey).getType() !== 'atomic') ) { return null; } const startKey = selection.getStartKey(); const blockBefore = content.getBlockBefore(startKey); // we want to delete that block completely if (blockBefore && blockBefore.getType() === 'atomic') { const withoutAtomicBlock = DraftModifier.removeRange( content, selection.merge({ focusKey: blockBefore.getKey(), focusOffset: blockBefore.getText().length, anchorKey: startKey, anchorOffset: content.getBlockForKey(startKey).getText().length, isBackward: false, }), 'forward', ).merge({ selectionAfter: selection, }); if (withoutAtomicBlock !== content) { return EditorState.push( editorState, withoutAtomicBlock, 'remove-range', ); } } // if we have a next sibling we should not allow the normal backspace // behaviour of moving this text into its parent // if (currentBlock.getPrevSiblingKey()) { // return editorState; // } // If that doesn't succeed, try to remove the current block style. const withoutBlockStyle = NestedRichTextEditorUtil.tryToRemoveBlockStyle(editorState); if (withoutBlockStyle) { return EditorState.push( editorState, withoutBlockStyle, withoutBlockStyle.getBlockMap().get(currentBlock.getKey()).getType() === 'unstyled' ? 'change-block-type' : 'adjust-depth', ); } return null; }, // Todo (T32099101) // onSplitNestedBlock() {}, // Todo (T32099101) // onSplitParent() {}, /** * Ensures that we can create nested blocks by changing the block type of * a ranged selection */ toggleBlockType: ( editorState: EditorState, blockType: DraftBlockType, ): EditorState => { const selection = editorState.getSelection(); const content = editorState.getCurrentContent(); const currentBlock = content.getBlockForKey(selection.getStartKey()); const haveChildren = !currentBlock.getChildKeys().isEmpty(); const isSelectionCollapsed = selection.isCollapsed(); const isMultiBlockSelection = selection.getAnchorKey() !== selection.getFocusKey(); const isUnsupportedNestingBlockType = NESTING_DISABLED_TYPES.includes(blockType); const isCurrentBlockOfUnsupportedNestingBlockType = NESTING_DISABLED_TYPES.includes(currentBlock.getType()); // we don't allow this operations to avoid corrupting the document data model // to make sure that non nested blockTypes wont inherit children if ( (isMultiBlockSelection || haveChildren) && isUnsupportedNestingBlockType ) { return editorState; } // we can treat this operations the same way as we would for flat data structures if ( isCurrentBlockOfUnsupportedNestingBlockType || isSelectionCollapsed || isUnsupportedNestingBlockType || isMultiBlockSelection || currentBlock.getType() === blockType || !currentBlock.getChildKeys().isEmpty() ) { return RichTextEditorUtil.toggleBlockType(editorState, blockType); } // TODO // if we have full range selection on the block: // extract text and insert a block after it with the text as its content // else // split the block into before range and after unstyled blocks // return editorState; }, currentBlockContainsLink: (editorState: EditorState): boolean => { const selection = editorState.getSelection(); const contentState = editorState.getCurrentContent(); const entityMap = contentState.getEntityMap(); return contentState .getBlockForKey(selection.getAnchorKey()) .getCharacterList() .slice(selection.getStartOffset(), selection.getEndOffset()) .some(v => { const entity = v.getEntity(); return !!entity && contentState.getEntity(entity).getType() === 'LINK'; }); }, getCurrentBlockType: (editorState: EditorState): DraftBlockType => { const selection = editorState.getSelection(); return editorState .getCurrentContent() .getBlockForKey(selection.getStartKey()) .getType(); }, getDataObjectForLinkURL: (uri: URI): DataObjectForLink => { return {url: uri.toString()}; }, insertSoftNewline: (editorState: EditorState): EditorState => { const contentState = DraftModifier.insertText( editorState.getCurrentContent(), editorState.getSelection(), '\n', editorState.getCurrentInlineStyle(), null, ); const newEditorState = EditorState.push( editorState, contentState, 'insert-characters', ); return EditorState.forceSelection( newEditorState, contentState.getSelectionAfter(), ); }, onTab: ( event: SyntheticKeyboardEvent<>, editorState: EditorState, ): EditorState => { const selection = editorState.getSelection(); const key = selection.getAnchorKey(); if (key !== selection.getFocusKey()) { return editorState; } let content = editorState.getCurrentContent(); const block = content.getBlockForKey(key); const type = block.getType(); if (type !== 'unordered-list-item' && type !== 'ordered-list-item') { return editorState; } event.preventDefault(); // implement nested tree behaviour for onTab let blockMap = editorState.getCurrentContent().getBlockMap(); const prevSiblingKey = block.getPrevSiblingKey(); const nextSiblingKey = block.getNextSiblingKey(); if (!event.shiftKey) { // if there is no previous sibling, we do nothing if (prevSiblingKey == null) { return editorState; } // if previous sibling is a non-leaf move node as child of previous sibling const prevSibling = blockMap.get(prevSiblingKey); const nextSibling = nextSiblingKey != null ? blockMap.get(nextSiblingKey) : null; const prevSiblingNonLeaf = prevSibling != null && prevSibling.getChildKeys().count() > 0; const nextSiblingNonLeaf = nextSibling != null && nextSibling.getChildKeys().count() > 0; if (prevSiblingNonLeaf) { blockMap = DraftTreeOperations.updateAsSiblingsChild( blockMap, key, 'previous', ); // if next sibling is also non-leaf, merge the previous & next siblings if (nextSiblingNonLeaf) { blockMap = DraftTreeOperations.mergeBlocks(blockMap, prevSiblingKey); } // else, if only next sibling is non-leaf move node as child of next sibling } else if (nextSiblingNonLeaf) { blockMap = DraftTreeOperations.updateAsSiblingsChild( blockMap, key, 'next', ); // if none of the siblings are non-leaf, we need to create a new parent } else { blockMap = DraftTreeOperations.createNewParent(blockMap, key); } // on un-tab } else { // if the block isn't nested, do nothing if (block.getParentKey() == null) { return editorState; } blockMap = onUntab(blockMap, block); } content = editorState.getCurrentContent().merge({ blockMap, }); const withAdjustment = adjustBlockDepthForContentState( content, selection, event.shiftKey ? -1 : 1, ); return EditorState.push(editorState, withAdjustment, 'adjust-depth'); }, toggleCode: (editorState: EditorState): EditorState => { const selection = editorState.getSelection(); const anchorKey = selection.getAnchorKey(); const focusKey = selection.getFocusKey(); if (selection.isCollapsed() || anchorKey !== focusKey) { return RichTextEditorUtil.toggleBlockType(editorState, 'code-block'); } return RichTextEditorUtil.toggleInlineStyle(editorState, 'CODE'); }, /** * Toggle the specified inline style for the selection. If the * user's selection is collapsed, apply or remove the style for the * internal state. If it is not collapsed, apply the change directly * to the document state. */ toggleInlineStyle: ( editorState: EditorState, inlineStyle: string, ): EditorState => { const selection = editorState.getSelection(); const currentStyle = editorState.getCurrentInlineStyle(); // If the selection is collapsed, toggle the specified style on or off and // set the result as the new inline style override. This will then be // used as the inline style for the next character to be inserted. if (selection.isCollapsed()) { return EditorState.setInlineStyleOverride( editorState, currentStyle.has(inlineStyle) ? currentStyle.remove(inlineStyle) : currentStyle.add(inlineStyle), ); } // If characters are selected, immediately apply or remove the // inline style on the document state itself. const content = editorState.getCurrentContent(); let newContent; // If the style is already present for the selection range, remove it. // Otherwise, apply it. if (currentStyle.has(inlineStyle)) { newContent = DraftModifier.removeInlineStyle( content, selection, inlineStyle, ); } else { newContent = DraftModifier.applyInlineStyle( content, selection, inlineStyle, ); } return EditorState.push(editorState, newContent, 'change-inline-style'); }, toggleLink: ( editorState: EditorState, targetSelection: SelectionState, entityKey: ?string, ): EditorState => { const withoutLink = DraftModifier.applyEntity( editorState.getCurrentContent(), targetSelection, entityKey, ); return EditorState.push(editorState, withoutLink, 'apply-entity'); }, /** * When a collapsed cursor is at the start of a styled block, changes block * type to 'unstyled'. Returns null if selection does not meet that criteria. */ tryToRemoveBlockStyle: (editorState: EditorState): ?ContentState => { const selection = editorState.getSelection(); const offset = selection.getAnchorOffset(); if (selection.isCollapsed() && offset === 0) { const key = selection.getAnchorKey(); const content = editorState.getCurrentContent(); const block = content.getBlockForKey(key); const type = block.getType(); const blockBefore = content.getBlockBefore(key); if ( type === 'code-block' && blockBefore && blockBefore.getType() === 'code-block' && blockBefore.getLength() !== 0 ) { return null; } const depth = block.getDepth(); if (type !== 'unstyled') { if ( (type === 'unordered-list-item' || type === 'ordered-list-item') && depth > 0 ) { let newBlockMap = onUntab(content.getBlockMap(), block); newBlockMap = newBlockMap.set( key, newBlockMap.get(key).merge({depth: depth - 1}), ); return content.merge({blockMap: newBlockMap}); } return DraftModifier.setBlockType(content, selection, 'unstyled'); } } return null; }, }; const onUntab = (blockMap: BlockMap, block: ContentBlockNode): BlockMap => { const key = block.getKey(); const parentKey = block.getParentKey(); const nextSiblingKey = block.getNextSiblingKey(); if (parentKey == null) { return blockMap; } const parent = blockMap.get(parentKey); const existingChildren = parent.getChildKeys(); const blockIndex = existingChildren.indexOf(key); if (blockIndex === 0 || blockIndex === existingChildren.count() - 1) { blockMap = DraftTreeOperations.moveChildUp(blockMap, key); } else { // split the block into [0, blockIndex] in parent & the rest in a new block const prevChildren = existingChildren.slice(0, blockIndex + 1); const nextChildren = existingChildren.slice(blockIndex + 1); blockMap = blockMap.set(parentKey, parent.merge({children: prevChildren})); const newBlock = new ContentBlockNode({ key: generateRandomKey(), text: '', depth: parent.getDepth(), type: parent.getType(), children: nextChildren, parent: parent.getParentKey(), }); // add new block just before its the original next sibling in the block map // TODO(T33894878): Remove the map reordering code & fix converter after launch invariant(nextSiblingKey != null, 'block must have a next sibling here'); const blocks = blockMap.toSeq(); blockMap = blocks .takeUntil(block => block.getKey() === nextSiblingKey) .concat( [[newBlock.getKey(), newBlock]], blocks.skipUntil(block => block.getKey() === nextSiblingKey), ) .toOrderedMap(); // set the nextChildren's parent to the new block blockMap = blockMap.map(block => nextChildren.includes(block.getKey()) ? block.merge({parent: newBlock.getKey()}) : block, ); // update the next/previous pointers for the children at the split blockMap = blockMap .set(key, block.merge({nextSibling: null})) .set( nextSiblingKey, blockMap.get(nextSiblingKey).merge({prevSibling: null}), ); const parentNextSiblingKey = parent.getNextSiblingKey(); if (parentNextSiblingKey != null) { blockMap = DraftTreeOperations.updateSibling( blockMap, newBlock.getKey(), parentNextSiblingKey, ); } blockMap = DraftTreeOperations.updateSibling( blockMap, parentKey, newBlock.getKey(), ); blockMap = DraftTreeOperations.moveChildUp(blockMap, key); } // on untab, we also want to unnest any sibling blocks that become two levels deep // ensure that block's old parent does not have a non-leaf as its first child. let childWasUntabbed = false; if (parentKey != null) { let parent = blockMap.get(parentKey); while (parent != null) { const children = parent.getChildKeys(); const firstChildKey = children.first(); invariant(firstChildKey != null, 'parent must have at least one child'); const firstChild = blockMap.get(firstChildKey); if (firstChild.getChildKeys().count() === 0) { break; } else { blockMap = DraftTreeOperations.moveChildUp(blockMap, firstChildKey); parent = blockMap.get(parentKey); childWasUntabbed = true; } } } // now, we may be in a state with two non-leaf blocks of the same type // next to each other if (childWasUntabbed && parentKey != null) { const parent = blockMap.get(parentKey); const prevSiblingKey = parent != null // parent may have been deleted ? parent.getPrevSiblingKey() : null; if (prevSiblingKey != null && parent.getChildKeys().count() > 0) { const prevSibling = blockMap.get(prevSiblingKey); if (prevSibling != null && prevSibling.getChildKeys().count() > 0) { blockMap = DraftTreeOperations.mergeBlocks(blockMap, prevSiblingKey); } } } return blockMap; }; module.exports = NestedRichTextEditorUtil;