/** * Copyright (c) 2013-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. * * @providesModule DraftEditor.react * @format * @flow * @preventMunge */ 'use strict'; 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 DraftEditorPlaceholder = require('./DraftEditorPlaceholder.react'); const EditorState = require('./EditorState'); const React = require('react'); const ReactDOM = require('react-dom'); 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 emptyFunction = require('fbjs/lib/emptyFunction'); const generateRandomKey = require('./generateRandomKey'); const getDefaultKeyBinding = require('./getDefaultKeyBinding'); const getScrollPosition = require('fbjs/lib/getScrollPosition'); const invariant = require('fbjs/lib/invariant'); 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 }; /** * `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 { static defaultProps: DraftEditorDefaultProps = { blockRenderMap: DefaultDraftBlockRenderMap, blockRendererFn: emptyFunction.thatReturnsNull, blockStyleFn: emptyFunction.thatReturns(''), 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; // 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 { return e => { if (!this.props.readOnly) { const method = this._handler && this._handler[eventName]; method && method(this, e); } }; } _showPlaceholder(): boolean { return !!this.props.placeholder && !this.props.editorState.isInCompositionMode() && !this.props.editorState.getCurrentContent().hasText(); } _renderPlaceholder(): ?React.Element { if (this._showPlaceholder()) { const placeHolderProps = { text: nullthrows(this.props.placeholder), editorState: this.props.editorState, textAlignment: this.props.textAlignment, accessibilityID: this._placeholderAccessibilityID }; return ; } return null; } render(): React.Node { const { blockRenderMap, blockRendererFn, blockStyleFn, customStyleFn, customStyleMap, editorState, 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. 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, key: 'contents' + this.state.contentsKey, textDirectionality }; return
{this._renderPlaceholder()}
this.editorContainer = ref}>
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={ref => this.editor = ref} role={readOnly ? null : ariaRole} spellCheck={allowSpellCheck && this.props.spellCheck} style={contentStyle} suppressContentEditableWarning tabIndex={this.props.tabIndex}>
; } componentDidMount(): void { 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) { document.execCommand('AutoUrlDetect', false, false); } } /** * Prevent selection events from affecting the current editor state. This * is mostly intended to defend against IE, which fires off `selectionchange` * events regardless of whether the selection is set via the browser or * programmatically. We only care about selection events that occur because * of browser interaction, not re-renders and forced selections. */ componentWillUpdate(nextProps: DraftEditorProps): void { this._blockSelectEvents = true; this._latestEditorState = nextProps.editorState; } componentDidUpdate(): void { this._blockSelectEvents = false; 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 => { const { editorState } = this.props; const alreadyHasFocus = editorState.getSelection().getHasFocus(); const editorNode = ReactDOM.findDOMNode(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(editorNode instanceof HTMLElement, '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 => { const editorNode = ReactDOM.findDOMNode(this.editor); invariant(editorNode instanceof HTMLElement, '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 = (mode: DraftEditorModes): void => { this._handler = handlerMap[mode]; }; exitCurrentMode = (): 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 => { this.setState({ contentsKey: this.state.contentsKey + 1 }, () => { this.focus(scrollPosition); }); }; /** * Used via `this.setClipboard(...)`. * * Set the clipboard state for a cut/copy event. */ setClipboard = (clipboard: ?BlockMap): void => { this._clipboard = clipboard; }; /** * Used via `this.getClipboard()`. * * Retrieve the clipboard state for a cut/copy event. */ getClipboard = (): ?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: 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 => { this._dragCount++; }; /** * See `onDragEnter()`. */ onDragLeave = (): void => { this._dragCount--; if (this._dragCount === 0) { this.exitCurrentMode(); } }; } module.exports = DraftEditor;