src/model/modifier/RichTextEditorUtil.js (286 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
*/
'use strict';
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 DraftModifier = require('DraftModifier');
const EditorState = require('EditorState');
const adjustBlockDepthForContentState = require('adjustBlockDepthForContentState');
const nullthrows = require('nullthrows');
const RichTextEditorUtil: RichTextUtils = {
currentBlockContainsLink(editorState: EditorState): boolean {
const selection = editorState.getSelection();
const contentState = editorState.getCurrentContent();
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()};
},
handleKeyCommand(
editorState: EditorState,
command: DraftEditorCommand | string,
eventTimeStamp: ?number,
): ?EditorState {
switch (command) {
case 'bold':
return RichTextEditorUtil.toggleInlineStyle(editorState, 'BOLD');
case 'italic':
return RichTextEditorUtil.toggleInlineStyle(editorState, 'ITALIC');
case 'underline':
return RichTextEditorUtil.toggleInlineStyle(editorState, 'UNDERLINE');
case 'strikethrough':
return RichTextEditorUtil.toggleInlineStyle(
editorState,
'STRIKETHROUGH',
);
case 'code':
return RichTextEditorUtil.toggleCode(editorState);
case 'backspace':
case 'backspace-word':
case 'backspace-to-start-of-line':
return RichTextEditorUtil.onBackspace(editorState);
case 'delete':
case 'delete-word':
case 'delete-to-end-of-block':
return RichTextEditorUtil.onDelete(editorState);
default:
// they may have custom editor commands; ignore those
return null;
}
},
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(),
);
},
/**
* For collapsed selections at the start of styled blocks, backspace should
* just remove the existing style.
*/
onBackspace(editorState: EditorState): ?EditorState {
const selection = editorState.getSelection();
if (
!selection.isCollapsed() ||
selection.getAnchorOffset() ||
selection.getFocusOffset()
) {
return null;
}
// First, try to remove a preceding atomic block.
const content = editorState.getCurrentContent();
const startKey = selection.getStartKey();
const blockBefore = content.getBlockBefore(startKey);
if (blockBefore && blockBefore.getType() === 'atomic') {
const blockMap = content.getBlockMap().delete(blockBefore.getKey());
const withoutAtomicBlock = content.merge({
blockMap,
selectionAfter: selection,
});
if (withoutAtomicBlock !== content) {
return EditorState.push(
editorState,
withoutAtomicBlock,
'remove-range',
);
}
}
// If that doesn't succeed, try to remove the current block style.
const withoutBlockStyle =
RichTextEditorUtil.tryToRemoveBlockStyle(editorState);
if (withoutBlockStyle) {
return EditorState.push(
editorState,
withoutBlockStyle,
'change-block-type',
);
}
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;
},
onTab(
event: SyntheticKeyboardEvent<>,
editorState: EditorState,
): EditorState {
const selection = editorState.getSelection();
const key = selection.getAnchorKey();
const 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();
const withAdjustment = adjustBlockDepthForContentState(
content,
selection,
event.shiftKey ? -1 : 1,
);
return EditorState.push(editorState, withAdjustment, 'adjust-depth');
},
toggleBlockType(
editorState: EditorState,
blockType: DraftBlockType,
): EditorState {
const selection = editorState.getSelection();
const startKey = selection.getStartKey();
let endKey = selection.getEndKey();
const content = editorState.getCurrentContent();
let target = selection;
// Triple-click can lead to a selection that includes offset 0 of the
// following block. The `SelectionState` for this case is accurate, but
// we should avoid toggling block type for the trailing block because it
// is a confusing interaction.
if (startKey !== endKey && selection.getEndOffset() === 0) {
const blockBefore = nullthrows(content.getBlockBefore(endKey));
endKey = blockBefore.getKey();
target = target.merge({
anchorKey: startKey,
anchorOffset: selection.getStartOffset(),
focusKey: endKey,
focusOffset: blockBefore.getLength(),
isBackward: false,
});
}
const hasAtomicBlock = content
.getBlockMap()
.skipWhile((_, k) => k !== startKey)
.reverse()
.skipWhile((_, k) => k !== endKey)
.some(v => v.getType() === 'atomic');
if (hasAtomicBlock) {
return editorState;
}
const typeToSet =
content.getBlockForKey(startKey).getType() === blockType
? 'unstyled'
: blockType;
return EditorState.push(
editorState,
DraftModifier.setBlockType(content, target, typeToSet),
'change-block-type',
);
},
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;
}
if (type !== 'unstyled') {
return DraftModifier.setBlockType(content, selection, 'unstyled');
}
}
return null;
},
};
module.exports = RichTextEditorUtil;