draft-js
Version:
A React framework for building text editors.
613 lines (531 loc) • 21.4 kB
Flow
/**
* 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
* @preventMunge
* @emails oncall+draft_js
*/
'use strict';
declare var __DEV__: boolean;
import type { BlockMap } from "./BlockMap";
import type { DraftEditorModes } from "./DraftEditorModes";
import type { DraftEditorDefaultProps, DraftEditorProps } from "./DraftEditorProps";
import type { DraftScrollPosition } from "./DraftScrollPosition";
const DefaultDraftBlockRenderMap = require("./DefaultDraftBlockRenderMap");
const DefaultDraftInlineStyle = require("./DefaultDraftInlineStyle");
const DraftEditorCompositionHandler = require("./DraftEditorCompositionHandler");
const DraftEditorContents = require("./DraftEditorContents.react");
const DraftEditorDragHandler = require("./DraftEditorDragHandler");
const DraftEditorEditHandler = require("./DraftEditorEditHandler");
const flushControlled = require("./DraftEditorFlushControlled");
const DraftEditorPlaceholder = require("./DraftEditorPlaceholder.react");
const DraftEffects = require("./DraftEffects");
const EditorState = require("./EditorState");
const React = require("react");
const Scroll = require("fbjs/lib/Scroll");
const Style = require("fbjs/lib/Style");
const UserAgent = require("fbjs/lib/UserAgent");
const cx = require("fbjs/lib/cx");
const generateRandomKey = require("./generateRandomKey");
const getDefaultKeyBinding = require("./getDefaultKeyBinding");
const getScrollPosition = require("fbjs/lib/getScrollPosition");
const gkx = require("./gkx");
const invariant = require("fbjs/lib/invariant");
const isHTMLElement = require("./isHTMLElement");
const nullthrows = require("fbjs/lib/nullthrows");
const isIE = UserAgent.isBrowser('IE'); // IE does not support the `input` event on contentEditable, so we can't
// observe spellcheck behavior.
const allowSpellCheck = !isIE; // Define a set of handler objects to correspond to each possible `mode`
// of editor behavior.
const handlerMap = {
edit: DraftEditorEditHandler,
composite: DraftEditorCompositionHandler,
drag: DraftEditorDragHandler,
cut: null,
render: null
};
type State = {
contentsKey: number,
...
};
let didInitODS = false;
class UpdateDraftEditorFlags extends React.Component<{
editor: DraftEditor,
editorState: EditorState,
...
}> {
render(): React.Node {
return null;
}
componentDidMount(): mixed {
this._update();
}
componentDidUpdate(): mixed {
this._update();
}
_update() {
const editor = this.props.editor;
/**
* Sometimes a render triggers a 'focus' or other event, and that will
* schedule a second render pass.
* In order to make sure the second render pass gets the latest editor
* state, we update it here.
* Example:
* render #1
* +
* |
* | cWU -> Nothing ... latestEditorState = STALE_STATE :(
* |
* | render -> this.props.editorState = FRESH_STATE
* | + *and* set latestEditorState = FRESH_STATE
* |
* | |
* | +--> triggers 'focus' event, calling 'handleFocus' with latestEditorState
* | +
* | |
* +>cdU -> latestEditorState = FRESH_STATE | the 'handleFocus' call schedules render #2
* | with latestEditorState, which is FRESH_STATE
* |
* render #2 <--------------------------------------+
* +
* |
* | cwU -> nothing updates
* |
* | render -> this.props.editorState = FRESH_STATE which was passed in above
* |
* +>cdU fires and resets latestEditorState = FRESH_STATE
* ---
* Note that if we don't set latestEditorState in 'render' in the above
* diagram, then STALE_STATE gets passed to render #2.
*/
editor._latestEditorState = this.props.editorState;
/**
* The reason we set this 'blockSelectEvents' flag is that IE will fire a
* 'selectionChange' event when we programmatically change the selection,
* meaning it would trigger a new select event while we are in the middle
* of updating.
* We found that the 'selection.addRange' was what triggered the stray
* selectionchange event in IE.
* To be clear - we have not been able to reproduce specific bugs related
* to this stray selection event, but have recorded logs that some
* conditions do cause it to get bumped into during editOnSelect.
*/
editor._blockSelectEvents = true;
}
}
/**
* `DraftEditor` is the root editor component. It composes a `contentEditable`
* div, and provides a wide variety of useful function props for managing the
* state of the editor. See `DraftEditorProps` for details.
*/
class DraftEditor extends React.Component<DraftEditorProps, State> {
static defaultProps: DraftEditorDefaultProps = {
ariaDescribedBy: '{{editor_id_placeholder}}',
blockRenderMap: DefaultDraftBlockRenderMap,
blockRendererFn: function () {
return null;
},
blockStyleFn: function () {
return '';
},
keyBindingFn: getDefaultKeyBinding,
readOnly: false,
spellCheck: false,
stripPastedStyles: false
};
_blockSelectEvents: boolean;
_clipboard: ?BlockMap;
_handler: ?Object;
_dragCount: number;
_internalDrag: boolean;
_editorKey: string;
_placeholderAccessibilityID: string;
_latestEditorState: EditorState;
_latestCommittedEditorState: EditorState;
_pendingStateFromBeforeInput: void | EditorState;
/**
* Define proxies that can route events to the current handler.
*/
_onBeforeInput: Function;
_onBlur: Function;
_onCharacterData: Function;
_onCompositionEnd: Function;
_onCompositionStart: Function;
_onCopy: Function;
_onCut: Function;
_onDragEnd: Function;
_onDragOver: Function;
_onDragStart: Function;
_onDrop: Function;
_onInput: Function;
_onFocus: Function;
_onKeyDown: Function;
_onKeyPress: Function;
_onKeyUp: Function;
_onMouseDown: Function;
_onMouseUp: Function;
_onPaste: Function;
_onSelect: Function;
editor: ?HTMLElement;
editorContainer: ?HTMLElement;
focus: () => void;
blur: () => void;
setMode: (mode: DraftEditorModes) => void;
exitCurrentMode: () => void;
restoreEditorDOM: (scrollPosition?: DraftScrollPosition) => void;
setClipboard: (clipboard: ?BlockMap) => void;
getClipboard: () => ?BlockMap;
getEditorKey: () => string;
update: (editorState: EditorState) => void;
onDragEnter: () => void;
onDragLeave: () => void;
constructor(props: DraftEditorProps) {
super(props);
this._blockSelectEvents = false;
this._clipboard = null;
this._handler = null;
this._dragCount = 0;
this._editorKey = props.editorKey || generateRandomKey();
this._placeholderAccessibilityID = 'placeholder-' + this._editorKey;
this._latestEditorState = props.editorState;
this._latestCommittedEditorState = props.editorState;
this._onBeforeInput = this._buildHandler('onBeforeInput');
this._onBlur = this._buildHandler('onBlur');
this._onCharacterData = this._buildHandler('onCharacterData');
this._onCompositionEnd = this._buildHandler('onCompositionEnd');
this._onCompositionStart = this._buildHandler('onCompositionStart');
this._onCopy = this._buildHandler('onCopy');
this._onCut = this._buildHandler('onCut');
this._onDragEnd = this._buildHandler('onDragEnd');
this._onDragOver = this._buildHandler('onDragOver');
this._onDragStart = this._buildHandler('onDragStart');
this._onDrop = this._buildHandler('onDrop');
this._onInput = this._buildHandler('onInput');
this._onFocus = this._buildHandler('onFocus');
this._onKeyDown = this._buildHandler('onKeyDown');
this._onKeyPress = this._buildHandler('onKeyPress');
this._onKeyUp = this._buildHandler('onKeyUp');
this._onMouseDown = this._buildHandler('onMouseDown');
this._onMouseUp = this._buildHandler('onMouseUp');
this._onPaste = this._buildHandler('onPaste');
this._onSelect = this._buildHandler('onSelect');
this.getEditorKey = () => this._editorKey;
if (__DEV__) {
['onDownArrow', 'onEscape', 'onLeftArrow', 'onRightArrow', 'onTab', 'onUpArrow'].forEach(propName => {
if (props.hasOwnProperty(propName)) {
// eslint-disable-next-line no-console
console.warn(`Supplying an \`${propName}\` prop to \`DraftEditor\` has ` + 'been deprecated. If your handler needs access to the keyboard ' + 'event, supply a custom `keyBindingFn` prop that falls back to ' + 'the default one (eg. https://is.gd/wHKQ3W).');
}
});
} // See `restoreEditorDOM()`.
this.state = {
contentsKey: 0
};
}
/**
* Build a method that will pass the event to the specified handler method.
* This allows us to look up the correct handler function for the current
* editor mode, if any has been specified.
*/
_buildHandler(eventName: string): Function {
// Wrap event handlers in `flushControlled`. In sync mode, this is
// effectively a no-op. In async mode, this ensures all updates scheduled
// inside the handler are flushed before React yields to the browser.
return e => {
if (!this.props.readOnly) {
const method = this._handler && this._handler[eventName];
if (method) {
if (flushControlled) {
flushControlled(() => method(this, e));
} else {
method(this, e);
}
}
}
};
}
_handleEditorContainerRef: (HTMLElement | null) => void = (node: HTMLElement | null): void => {
this.editorContainer = node; // Instead of having a direct ref on the child, we'll grab it here.
// This is safe as long as the rendered structure is static (which it is).
// This lets the child support ref={props.editorRef} without merging refs.
this.editor = node !== null ? (node: any).firstChild : null;
};
_showPlaceholder(): boolean {
return !!this.props.placeholder && !this.props.editorState.isInCompositionMode() && !this.props.editorState.getCurrentContent().hasText();
}
_renderPlaceholder(): React.Node {
if (this._showPlaceholder()) {
const placeHolderProps = {
text: nullthrows(this.props.placeholder),
editorState: this.props.editorState,
textAlignment: this.props.textAlignment,
accessibilityID: this._placeholderAccessibilityID
};
/* $FlowFixMe[incompatible-type] (>=0.112.0 site=www,mobile) This comment
* suppresses an error found when Flow v0.112 was deployed. To see the
* error delete this comment and run Flow. */
return <DraftEditorPlaceholder {...placeHolderProps} />;
}
return null;
}
/**
* returns ariaDescribedBy prop with '{{editor_id_placeholder}}' replaced with
* the DOM id of the placeholder (if it exists)
* @returns aria-describedby attribute value
*/
_renderARIADescribedBy(): ?string {
const describedBy = this.props.ariaDescribedBy || '';
const placeholderID = this._showPlaceholder() ? this._placeholderAccessibilityID : '';
return describedBy.replace('{{editor_id_placeholder}}', placeholderID) || undefined;
}
render(): React.Node {
const {
blockRenderMap,
blockRendererFn,
blockStyleFn,
customStyleFn,
customStyleMap,
editorState,
preventScroll,
readOnly,
textAlignment,
textDirectionality
} = this.props;
const rootClass = cx({
'DraftEditor/root': true,
'DraftEditor/alignLeft': textAlignment === 'left',
'DraftEditor/alignRight': textAlignment === 'right',
'DraftEditor/alignCenter': textAlignment === 'center'
});
const contentStyle = {
outline: 'none',
// fix parent-draggable Safari bug. #1326
userSelect: 'text',
WebkitUserSelect: 'text',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word'
}; // The aria-expanded and aria-haspopup properties should only be rendered
// for a combobox.
/* $FlowFixMe[prop-missing] (>=0.68.0 site=www,mobile) This comment
* suppresses an error found when Flow v0.68 was deployed. To see the error
* delete this comment and run Flow. */
const ariaRole = this.props.role || 'textbox';
const ariaExpanded = ariaRole === 'combobox' ? !!this.props.ariaExpanded : null;
const editorContentsProps = {
blockRenderMap,
blockRendererFn,
blockStyleFn,
customStyleMap: { ...DefaultDraftInlineStyle,
...customStyleMap
},
customStyleFn,
editorKey: this._editorKey,
editorState,
preventScroll,
textDirectionality
};
return <div className={rootClass}>
{this._renderPlaceholder()}
<div className={cx('DraftEditor/editorContainer')} ref={this._handleEditorContainerRef}>
{
/* Note: _handleEditorContainerRef assumes this div won't move: */
}
<div aria-activedescendant={readOnly ? null : this.props.ariaActiveDescendantID} aria-autocomplete={readOnly ? null : this.props.ariaAutoComplete} aria-controls={readOnly ? null : this.props.ariaControls} aria-describedby={this._renderARIADescribedBy()} aria-expanded={readOnly ? null : ariaExpanded} aria-label={this.props.ariaLabel} aria-labelledby={this.props.ariaLabelledBy} aria-multiline={this.props.ariaMultiline} aria-owns={readOnly ? null : this.props.ariaOwneeID} autoCapitalize={this.props.autoCapitalize} autoComplete={this.props.autoComplete} autoCorrect={this.props.autoCorrect} className={cx({
// Chrome's built-in translation feature mutates the DOM in ways
// that Draft doesn't expect (ex: adding <font> tags inside
// DraftEditorLeaf spans) and causes problems. We add notranslate
// here which makes its autotranslation skip over this subtree.
notranslate: !readOnly,
'public/DraftEditor/content': true
})} contentEditable={!readOnly} data-testid={this.props.webDriverTestID} onBeforeInput={this._onBeforeInput} onBlur={this._onBlur} onCompositionEnd={this._onCompositionEnd} onCompositionStart={this._onCompositionStart} onCopy={this._onCopy} onCut={this._onCut} onDragEnd={this._onDragEnd} onDragEnter={this.onDragEnter} onDragLeave={this.onDragLeave} onDragOver={this._onDragOver} onDragStart={this._onDragStart} onDrop={this._onDrop} onFocus={this._onFocus} onInput={this._onInput} onKeyDown={this._onKeyDown} onKeyPress={this._onKeyPress} onKeyUp={this._onKeyUp} onMouseUp={this._onMouseUp} onPaste={this._onPaste} onSelect={this._onSelect} ref={this.props.editorRef} role={readOnly ? null : ariaRole} spellCheck={allowSpellCheck && this.props.spellCheck} style={contentStyle} suppressContentEditableWarning tabIndex={this.props.tabIndex}>
{
/*
Needs to come earlier in the tree as a sibling (not ancestor) of
all DraftEditorLeaf nodes so it's first in postorder traversal.
*/
}
<UpdateDraftEditorFlags editor={this} editorState={editorState} />
<DraftEditorContents {...editorContentsProps} key={'contents' + this.state.contentsKey} />
</div>
</div>
</div>;
}
componentDidMount(): void {
this._blockSelectEvents = false;
if (!didInitODS && gkx('draft_ods_enabled')) {
didInitODS = true;
DraftEffects.initODS();
}
this.setMode('edit');
/**
* IE has a hardcoded "feature" that attempts to convert link text into
* anchors in contentEditable DOM. This breaks the editor's expectations of
* the DOM, and control is lost. Disable it to make IE behave.
* See: http://blogs.msdn.com/b/ieinternals/archive/2010/09/15/
* ie9-beta-minor-change-list.aspx
*/
if (isIE) {
// editor can be null after mounting
// https://stackoverflow.com/questions/44074747/componentdidmount-called-before-ref-callback
if (!this.editor) {
global.execCommand('AutoUrlDetect', false, false);
} else {
this.editor.ownerDocument.execCommand('AutoUrlDetect', false, false);
}
}
}
componentDidUpdate(): void {
this._blockSelectEvents = false;
this._latestEditorState = this.props.editorState;
this._latestCommittedEditorState = this.props.editorState;
}
/**
* Used via `this.focus()`.
*
* Force focus back onto the editor node.
*
* We attempt to preserve scroll position when focusing. You can also pass
* a specified scroll position (for cases like `cut` behavior where it should
* be restored to a known position).
*/
focus: (scrollPosition?: DraftScrollPosition) => void = (scrollPosition?: DraftScrollPosition): void => {
const {
editorState
} = this.props;
const alreadyHasFocus = editorState.getSelection().getHasFocus();
const editorNode = this.editor;
if (!editorNode) {
// once in a while people call 'focus' in a setTimeout, and the node has
// been deleted, so it can be null in that case.
return;
}
const scrollParent = Style.getScrollParent(editorNode);
const {
x,
y
} = scrollPosition || getScrollPosition(scrollParent);
invariant(isHTMLElement(editorNode), 'editorNode is not an HTMLElement');
editorNode.focus(); // Restore scroll position
if (scrollParent === window) {
window.scrollTo(x, y);
} else {
Scroll.setTop(scrollParent, y);
} // On Chrome and Safari, calling focus on contenteditable focuses the
// cursor at the first character. This is something you don't expect when
// you're clicking on an input element but not directly on a character.
// Put the cursor back where it was before the blur.
if (!alreadyHasFocus) {
this.update(EditorState.forceSelection(editorState, editorState.getSelection()));
}
};
blur: () => void = (): void => {
const editorNode = this.editor;
if (!editorNode) {
return;
}
invariant(isHTMLElement(editorNode), 'editorNode is not an HTMLElement');
editorNode.blur();
};
/**
* Used via `this.setMode(...)`.
*
* Set the behavior mode for the editor component. This switches the current
* handler module to ensure that DOM events are managed appropriately for
* the active mode.
*/
setMode: (DraftEditorModes) => void = (mode: DraftEditorModes): void => {
const {
onPaste,
onCut,
onCopy
} = this.props;
const editHandler = { ...handlerMap.edit
};
if (onPaste) {
/* $FlowFixMe[incompatible-type] (>=0.117.0 site=www,mobile) This comment
* suppresses an error found when Flow v0.117 was deployed. To see the
* error delete this comment and run Flow. */
editHandler.onPaste = onPaste;
}
if (onCut) {
editHandler.onCut = onCut;
}
if (onCopy) {
editHandler.onCopy = onCopy;
}
const handler = { ...handlerMap,
edit: editHandler
};
this._handler = handler[mode];
};
exitCurrentMode: () => void = (): void => {
this.setMode('edit');
};
/**
* Used via `this.restoreEditorDOM()`.
*
* Force a complete re-render of the DraftEditorContents based on the current
* EditorState. This is useful when we know we are going to lose control of
* the DOM state (cut command, IME) and we want to make sure that
* reconciliation occurs on a version of the DOM that is synchronized with
* our EditorState.
*/
restoreEditorDOM: (scrollPosition?: DraftScrollPosition) => void = (scrollPosition?: DraftScrollPosition): void => {
this.setState({
contentsKey: this.state.contentsKey + 1
}, () => {
this.focus(scrollPosition);
});
};
/**
* Used via `this.setClipboard(...)`.
*
* Set the clipboard state for a cut/copy event.
*/
setClipboard: (?BlockMap) => void = (clipboard: ?BlockMap): void => {
this._clipboard = clipboard;
};
/**
* Used via `this.getClipboard()`.
*
* Retrieve the clipboard state for a cut/copy event.
*/
getClipboard: () => ?BlockMap = (): ?BlockMap => {
return this._clipboard;
};
/**
* Used via `this.update(...)`.
*
* Propagate a new `EditorState` object to higher-level components. This is
* the method by which event handlers inform the `DraftEditor` component of
* state changes. A component that composes a `DraftEditor` **must** provide
* an `onChange` prop to receive state updates passed along from this
* function.
*/
update: (EditorState) => void = (editorState: EditorState): void => {
this._latestEditorState = editorState;
this.props.onChange(editorState);
};
/**
* Used in conjunction with `onDragLeave()`, by counting the number of times
* a dragged element enters and leaves the editor (or any of its children),
* to determine when the dragged element absolutely leaves the editor.
*/
onDragEnter: () => void = (): void => {
this._dragCount++;
};
/**
* See `onDragEnter()`.
*/
onDragLeave: () => void = (): void => {
this._dragCount--;
if (this._dragCount === 0) {
this.exitCurrentMode();
}
};
}
module.exports = DraftEditor;