/** * 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 { 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 ; } 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
{this._renderPlaceholder()}
{ /* Note: _handleEditorContainerRef assumes this div won't move: */ }
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. */ }
; } 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;