Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

600 строки
20 KiB

  1. /**
  2. * Copyright (c) Facebook, Inc. and its affiliates.
  3. *
  4. * This source code is licensed under the MIT license found in the
  5. * LICENSE file in the root directory of this source tree.
  6. *
  7. * @format
  8. * @flow
  9. * @preventMunge
  10. * @emails oncall+draft_js
  11. */
  12. 'use strict';
  13. declare var __DEV__: boolean;
  14. import type { BlockMap } from "./BlockMap";
  15. import type { DraftEditorModes } from "./DraftEditorModes";
  16. import type { DraftEditorDefaultProps, DraftEditorProps } from "./DraftEditorProps";
  17. import type { DraftScrollPosition } from "./DraftScrollPosition";
  18. const DefaultDraftBlockRenderMap = require("./DefaultDraftBlockRenderMap");
  19. const DefaultDraftInlineStyle = require("./DefaultDraftInlineStyle");
  20. const DraftEditorCompositionHandler = require("./DraftEditorCompositionHandler");
  21. const DraftEditorContents = require("./DraftEditorContents.react");
  22. const DraftEditorDragHandler = require("./DraftEditorDragHandler");
  23. const DraftEditorEditHandler = require("./DraftEditorEditHandler");
  24. const flushControlled = require("./DraftEditorFlushControlled");
  25. const DraftEditorPlaceholder = require("./DraftEditorPlaceholder.react");
  26. const DraftEffects = require("./DraftEffects");
  27. const EditorState = require("./EditorState");
  28. const React = require("react");
  29. const Scroll = require("fbjs/lib/Scroll");
  30. const Style = require("fbjs/lib/Style");
  31. const UserAgent = require("fbjs/lib/UserAgent");
  32. const cx = require("fbjs/lib/cx");
  33. const generateRandomKey = require("./generateRandomKey");
  34. const getDefaultKeyBinding = require("./getDefaultKeyBinding");
  35. const getScrollPosition = require("fbjs/lib/getScrollPosition");
  36. const gkx = require("./gkx");
  37. const invariant = require("fbjs/lib/invariant");
  38. const isHTMLElement = require("./isHTMLElement");
  39. const nullthrows = require("fbjs/lib/nullthrows");
  40. const isIE = UserAgent.isBrowser('IE'); // IE does not support the `input` event on contentEditable, so we can't
  41. // observe spellcheck behavior.
  42. const allowSpellCheck = !isIE; // Define a set of handler objects to correspond to each possible `mode`
  43. // of editor behavior.
  44. const handlerMap = {
  45. edit: DraftEditorEditHandler,
  46. composite: DraftEditorCompositionHandler,
  47. drag: DraftEditorDragHandler,
  48. cut: null,
  49. render: null
  50. };
  51. type State = {
  52. contentsKey: number,
  53. ...
  54. };
  55. let didInitODS = false;
  56. class UpdateDraftEditorFlags extends React.Component<{
  57. editor: DraftEditor,
  58. editorState: EditorState,
  59. ...
  60. }> {
  61. render(): React.Node {
  62. return null;
  63. }
  64. componentDidMount(): mixed {
  65. this._update();
  66. }
  67. componentDidUpdate(): mixed {
  68. this._update();
  69. }
  70. _update() {
  71. const editor = this.props.editor;
  72. /**
  73. * Sometimes a render triggers a 'focus' or other event, and that will
  74. * schedule a second render pass.
  75. * In order to make sure the second render pass gets the latest editor
  76. * state, we update it here.
  77. * Example:
  78. * render #1
  79. * +
  80. * |
  81. * | cWU -> Nothing ... latestEditorState = STALE_STATE :(
  82. * |
  83. * | render -> this.props.editorState = FRESH_STATE
  84. * | + *and* set latestEditorState = FRESH_STATE
  85. * |
  86. * | |
  87. * | +--> triggers 'focus' event, calling 'handleFocus' with latestEditorState
  88. * | +
  89. * | |
  90. * +>cdU -> latestEditorState = FRESH_STATE | the 'handleFocus' call schedules render #2
  91. * | with latestEditorState, which is FRESH_STATE
  92. * |
  93. * render #2 <--------------------------------------+
  94. * +
  95. * |
  96. * | cwU -> nothing updates
  97. * |
  98. * | render -> this.props.editorState = FRESH_STATE which was passed in above
  99. * |
  100. * +>cdU fires and resets latestEditorState = FRESH_STATE
  101. * ---
  102. * Note that if we don't set latestEditorState in 'render' in the above
  103. * diagram, then STALE_STATE gets passed to render #2.
  104. */
  105. editor._latestEditorState = this.props.editorState;
  106. /**
  107. * The reason we set this 'blockSelectEvents' flag is that IE will fire a
  108. * 'selectionChange' event when we programmatically change the selection,
  109. * meaning it would trigger a new select event while we are in the middle
  110. * of updating.
  111. * We found that the 'selection.addRange' was what triggered the stray
  112. * selectionchange event in IE.
  113. * To be clear - we have not been able to reproduce specific bugs related
  114. * to this stray selection event, but have recorded logs that some
  115. * conditions do cause it to get bumped into during editOnSelect.
  116. */
  117. editor._blockSelectEvents = true;
  118. }
  119. }
  120. /**
  121. * `DraftEditor` is the root editor component. It composes a `contentEditable`
  122. * div, and provides a wide variety of useful function props for managing the
  123. * state of the editor. See `DraftEditorProps` for details.
  124. */
  125. class DraftEditor extends React.Component<DraftEditorProps, State> {
  126. static defaultProps: DraftEditorDefaultProps = {
  127. blockRenderMap: DefaultDraftBlockRenderMap,
  128. blockRendererFn: function () {
  129. return null;
  130. },
  131. blockStyleFn: function () {
  132. return '';
  133. },
  134. keyBindingFn: getDefaultKeyBinding,
  135. readOnly: false,
  136. spellCheck: false,
  137. stripPastedStyles: false
  138. };
  139. _blockSelectEvents: boolean;
  140. _clipboard: ?BlockMap;
  141. _handler: ?Object;
  142. _dragCount: number;
  143. _internalDrag: boolean;
  144. _editorKey: string;
  145. _placeholderAccessibilityID: string;
  146. _latestEditorState: EditorState;
  147. _latestCommittedEditorState: EditorState;
  148. _pendingStateFromBeforeInput: void | EditorState;
  149. /**
  150. * Define proxies that can route events to the current handler.
  151. */
  152. _onBeforeInput: Function;
  153. _onBlur: Function;
  154. _onCharacterData: Function;
  155. _onCompositionEnd: Function;
  156. _onCompositionStart: Function;
  157. _onCopy: Function;
  158. _onCut: Function;
  159. _onDragEnd: Function;
  160. _onDragOver: Function;
  161. _onDragStart: Function;
  162. _onDrop: Function;
  163. _onInput: Function;
  164. _onFocus: Function;
  165. _onKeyDown: Function;
  166. _onKeyPress: Function;
  167. _onKeyUp: Function;
  168. _onMouseDown: Function;
  169. _onMouseUp: Function;
  170. _onPaste: Function;
  171. _onSelect: Function;
  172. editor: ?HTMLElement;
  173. editorContainer: ?HTMLElement;
  174. focus: () => void;
  175. blur: () => void;
  176. setMode: (mode: DraftEditorModes) => void;
  177. exitCurrentMode: () => void;
  178. restoreEditorDOM: (scrollPosition?: DraftScrollPosition) => void;
  179. setClipboard: (clipboard: ?BlockMap) => void;
  180. getClipboard: () => ?BlockMap;
  181. getEditorKey: () => string;
  182. update: (editorState: EditorState) => void;
  183. onDragEnter: () => void;
  184. onDragLeave: () => void;
  185. constructor(props: DraftEditorProps) {
  186. super(props);
  187. this._blockSelectEvents = false;
  188. this._clipboard = null;
  189. this._handler = null;
  190. this._dragCount = 0;
  191. this._editorKey = props.editorKey || generateRandomKey();
  192. this._placeholderAccessibilityID = 'placeholder-' + this._editorKey;
  193. this._latestEditorState = props.editorState;
  194. this._latestCommittedEditorState = props.editorState;
  195. this._onBeforeInput = this._buildHandler('onBeforeInput');
  196. this._onBlur = this._buildHandler('onBlur');
  197. this._onCharacterData = this._buildHandler('onCharacterData');
  198. this._onCompositionEnd = this._buildHandler('onCompositionEnd');
  199. this._onCompositionStart = this._buildHandler('onCompositionStart');
  200. this._onCopy = this._buildHandler('onCopy');
  201. this._onCut = this._buildHandler('onCut');
  202. this._onDragEnd = this._buildHandler('onDragEnd');
  203. this._onDragOver = this._buildHandler('onDragOver');
  204. this._onDragStart = this._buildHandler('onDragStart');
  205. this._onDrop = this._buildHandler('onDrop');
  206. this._onInput = this._buildHandler('onInput');
  207. this._onFocus = this._buildHandler('onFocus');
  208. this._onKeyDown = this._buildHandler('onKeyDown');
  209. this._onKeyPress = this._buildHandler('onKeyPress');
  210. this._onKeyUp = this._buildHandler('onKeyUp');
  211. this._onMouseDown = this._buildHandler('onMouseDown');
  212. this._onMouseUp = this._buildHandler('onMouseUp');
  213. this._onPaste = this._buildHandler('onPaste');
  214. this._onSelect = this._buildHandler('onSelect');
  215. this.getEditorKey = () => this._editorKey;
  216. if (__DEV__) {
  217. ['onDownArrow', 'onEscape', 'onLeftArrow', 'onRightArrow', 'onTab', 'onUpArrow'].forEach(propName => {
  218. if (props.hasOwnProperty(propName)) {
  219. // eslint-disable-next-line no-console
  220. 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).');
  221. }
  222. });
  223. } // See `restoreEditorDOM()`.
  224. this.state = {
  225. contentsKey: 0
  226. };
  227. }
  228. /**
  229. * Build a method that will pass the event to the specified handler method.
  230. * This allows us to look up the correct handler function for the current
  231. * editor mode, if any has been specified.
  232. */
  233. _buildHandler(eventName: string): Function {
  234. // Wrap event handlers in `flushControlled`. In sync mode, this is
  235. // effectively a no-op. In async mode, this ensures all updates scheduled
  236. // inside the handler are flushed before React yields to the browser.
  237. return e => {
  238. if (!this.props.readOnly) {
  239. const method = this._handler && this._handler[eventName];
  240. if (method) {
  241. if (flushControlled) {
  242. flushControlled(() => method(this, e));
  243. } else {
  244. method(this, e);
  245. }
  246. }
  247. }
  248. };
  249. }
  250. _handleEditorContainerRef: (HTMLElement | null) => void = (node: HTMLElement | null): void => {
  251. this.editorContainer = node; // Instead of having a direct ref on the child, we'll grab it here.
  252. // This is safe as long as the rendered structure is static (which it is).
  253. // This lets the child support ref={props.editorRef} without merging refs.
  254. this.editor = node !== null ? (node: any).firstChild : null;
  255. };
  256. _showPlaceholder(): boolean {
  257. return !!this.props.placeholder && !this.props.editorState.isInCompositionMode() && !this.props.editorState.getCurrentContent().hasText();
  258. }
  259. _renderPlaceholder(): React.Node {
  260. if (this._showPlaceholder()) {
  261. const placeHolderProps = {
  262. text: nullthrows(this.props.placeholder),
  263. editorState: this.props.editorState,
  264. textAlignment: this.props.textAlignment,
  265. accessibilityID: this._placeholderAccessibilityID
  266. };
  267. /* $FlowFixMe(>=0.112.0 site=www,mobile) This comment suppresses an error
  268. * found when Flow v0.112 was deployed. To see the error delete this
  269. * comment and run Flow. */
  270. return <DraftEditorPlaceholder {...placeHolderProps} />;
  271. }
  272. return null;
  273. }
  274. render(): React.Node {
  275. const {
  276. blockRenderMap,
  277. blockRendererFn,
  278. blockStyleFn,
  279. customStyleFn,
  280. customStyleMap,
  281. editorState,
  282. preventScroll,
  283. readOnly,
  284. textAlignment,
  285. textDirectionality
  286. } = this.props;
  287. const rootClass = cx({
  288. 'DraftEditor/root': true,
  289. 'DraftEditor/alignLeft': textAlignment === 'left',
  290. 'DraftEditor/alignRight': textAlignment === 'right',
  291. 'DraftEditor/alignCenter': textAlignment === 'center'
  292. });
  293. const contentStyle = {
  294. outline: 'none',
  295. // fix parent-draggable Safari bug. #1326
  296. userSelect: 'text',
  297. WebkitUserSelect: 'text',
  298. whiteSpace: 'pre-wrap',
  299. wordWrap: 'break-word'
  300. }; // The aria-expanded and aria-haspopup properties should only be rendered
  301. // for a combobox.
  302. /* $FlowFixMe(>=0.68.0 site=www,mobile) This comment suppresses an error
  303. * found when Flow v0.68 was deployed. To see the error delete this comment
  304. * and run Flow. */
  305. const ariaRole = this.props.role || 'textbox';
  306. const ariaExpanded = ariaRole === 'combobox' ? !!this.props.ariaExpanded : null;
  307. const editorContentsProps = {
  308. blockRenderMap,
  309. blockRendererFn,
  310. blockStyleFn,
  311. customStyleMap: { ...DefaultDraftInlineStyle,
  312. ...customStyleMap
  313. },
  314. customStyleFn,
  315. editorKey: this._editorKey,
  316. editorState,
  317. preventScroll,
  318. textDirectionality
  319. };
  320. return <div className={rootClass}>
  321. {this._renderPlaceholder()}
  322. <div className={cx('DraftEditor/editorContainer')} ref={this._handleEditorContainerRef}>
  323. {
  324. /* Note: _handleEditorContainerRef assumes this div won't move: */
  325. }
  326. <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.props.ariaDescribedBy || this._placeholderAccessibilityID} 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({
  327. // Chrome's built-in translation feature mutates the DOM in ways
  328. // that Draft doesn't expect (ex: adding <font> tags inside
  329. // DraftEditorLeaf spans) and causes problems. We add notranslate
  330. // here which makes its autotranslation skip over this subtree.
  331. notranslate: !readOnly,
  332. 'public/DraftEditor/content': true
  333. })} 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}>
  334. {
  335. /*
  336. Needs to come earlier in the tree as a sibling (not ancestor) of
  337. all DraftEditorLeaf nodes so it's first in postorder traversal.
  338. */
  339. }
  340. <UpdateDraftEditorFlags editor={this} editorState={editorState} />
  341. <DraftEditorContents {...editorContentsProps} key={'contents' + this.state.contentsKey} />
  342. </div>
  343. </div>
  344. </div>;
  345. }
  346. componentDidMount(): void {
  347. this._blockSelectEvents = false;
  348. if (!didInitODS && gkx('draft_ods_enabled')) {
  349. didInitODS = true;
  350. DraftEffects.initODS();
  351. }
  352. this.setMode('edit');
  353. /**
  354. * IE has a hardcoded "feature" that attempts to convert link text into
  355. * anchors in contentEditable DOM. This breaks the editor's expectations of
  356. * the DOM, and control is lost. Disable it to make IE behave.
  357. * See: http://blogs.msdn.com/b/ieinternals/archive/2010/09/15/
  358. * ie9-beta-minor-change-list.aspx
  359. */
  360. if (isIE) {
  361. // editor can be null after mounting
  362. // https://stackoverflow.com/questions/44074747/componentdidmount-called-before-ref-callback
  363. if (!this.editor) {
  364. global.execCommand('AutoUrlDetect', false, false);
  365. } else {
  366. this.editor.ownerDocument.execCommand('AutoUrlDetect', false, false);
  367. }
  368. }
  369. }
  370. componentDidUpdate(): void {
  371. this._blockSelectEvents = false;
  372. this._latestEditorState = this.props.editorState;
  373. this._latestCommittedEditorState = this.props.editorState;
  374. }
  375. /**
  376. * Used via `this.focus()`.
  377. *
  378. * Force focus back onto the editor node.
  379. *
  380. * We attempt to preserve scroll position when focusing. You can also pass
  381. * a specified scroll position (for cases like `cut` behavior where it should
  382. * be restored to a known position).
  383. */
  384. focus: (scrollPosition?: DraftScrollPosition) => void = (scrollPosition?: DraftScrollPosition): void => {
  385. const {
  386. editorState
  387. } = this.props;
  388. const alreadyHasFocus = editorState.getSelection().getHasFocus();
  389. const editorNode = this.editor;
  390. if (!editorNode) {
  391. // once in a while people call 'focus' in a setTimeout, and the node has
  392. // been deleted, so it can be null in that case.
  393. return;
  394. }
  395. const scrollParent = Style.getScrollParent(editorNode);
  396. const {
  397. x,
  398. y
  399. } = scrollPosition || getScrollPosition(scrollParent);
  400. invariant(isHTMLElement(editorNode), 'editorNode is not an HTMLElement');
  401. editorNode.focus(); // Restore scroll position
  402. if (scrollParent === window) {
  403. window.scrollTo(x, y);
  404. } else {
  405. Scroll.setTop(scrollParent, y);
  406. } // On Chrome and Safari, calling focus on contenteditable focuses the
  407. // cursor at the first character. This is something you don't expect when
  408. // you're clicking on an input element but not directly on a character.
  409. // Put the cursor back where it was before the blur.
  410. if (!alreadyHasFocus) {
  411. this.update(EditorState.forceSelection(editorState, editorState.getSelection()));
  412. }
  413. };
  414. blur: () => void = (): void => {
  415. const editorNode = this.editor;
  416. if (!editorNode) {
  417. return;
  418. }
  419. invariant(isHTMLElement(editorNode), 'editorNode is not an HTMLElement');
  420. editorNode.blur();
  421. };
  422. /**
  423. * Used via `this.setMode(...)`.
  424. *
  425. * Set the behavior mode for the editor component. This switches the current
  426. * handler module to ensure that DOM events are managed appropriately for
  427. * the active mode.
  428. */
  429. setMode: (DraftEditorModes) => void = (mode: DraftEditorModes): void => {
  430. const {
  431. onPaste,
  432. onCut,
  433. onCopy
  434. } = this.props;
  435. const editHandler = { ...handlerMap.edit
  436. };
  437. if (onPaste) {
  438. /* $FlowFixMe(>=0.117.0 site=www) This comment suppresses an error found
  439. * when Flow v0.117 was deployed. To see the error delete this comment
  440. * and run Flow. */
  441. editHandler.onPaste = onPaste;
  442. }
  443. if (onCut) {
  444. editHandler.onCut = onCut;
  445. }
  446. if (onCopy) {
  447. editHandler.onCopy = onCopy;
  448. }
  449. const handler = { ...handlerMap,
  450. edit: editHandler
  451. };
  452. this._handler = handler[mode];
  453. };
  454. exitCurrentMode: () => void = (): void => {
  455. this.setMode('edit');
  456. };
  457. /**
  458. * Used via `this.restoreEditorDOM()`.
  459. *
  460. * Force a complete re-render of the DraftEditorContents based on the current
  461. * EditorState. This is useful when we know we are going to lose control of
  462. * the DOM state (cut command, IME) and we want to make sure that
  463. * reconciliation occurs on a version of the DOM that is synchronized with
  464. * our EditorState.
  465. */
  466. restoreEditorDOM: (scrollPosition?: DraftScrollPosition) => void = (scrollPosition?: DraftScrollPosition): void => {
  467. this.setState({
  468. contentsKey: this.state.contentsKey + 1
  469. }, () => {
  470. this.focus(scrollPosition);
  471. });
  472. };
  473. /**
  474. * Used via `this.setClipboard(...)`.
  475. *
  476. * Set the clipboard state for a cut/copy event.
  477. */
  478. setClipboard: (?BlockMap) => void = (clipboard: ?BlockMap): void => {
  479. this._clipboard = clipboard;
  480. };
  481. /**
  482. * Used via `this.getClipboard()`.
  483. *
  484. * Retrieve the clipboard state for a cut/copy event.
  485. */
  486. getClipboard: () => ?BlockMap = (): ?BlockMap => {
  487. return this._clipboard;
  488. };
  489. /**
  490. * Used via `this.update(...)`.
  491. *
  492. * Propagate a new `EditorState` object to higher-level components. This is
  493. * the method by which event handlers inform the `DraftEditor` component of
  494. * state changes. A component that composes a `DraftEditor` **must** provide
  495. * an `onChange` prop to receive state updates passed along from this
  496. * function.
  497. */
  498. update: (EditorState) => void = (editorState: EditorState): void => {
  499. this._latestEditorState = editorState;
  500. this.props.onChange(editorState);
  501. };
  502. /**
  503. * Used in conjunction with `onDragLeave()`, by counting the number of times
  504. * a dragged element enters and leaves the editor (or any of its children),
  505. * to determine when the dragged element absolutely leaves the editor.
  506. */
  507. onDragEnter: () => void = (): void => {
  508. this._dragCount++;
  509. };
  510. /**
  511. * See `onDragEnter()`.
  512. */
  513. onDragLeave: () => void = (): void => {
  514. this._dragCount--;
  515. if (this._dragCount === 0) {
  516. this.exitCurrentMode();
  517. }
  518. };
  519. }
  520. module.exports = DraftEditor;