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

465 строки
18 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. * @emails oncall+draft_js
  10. *
  11. * This is unstable and not part of the public API and should not be used by
  12. * production systems. This file may be update/removed without notice.
  13. */
  14. import type { BlockMap } from "./BlockMap";
  15. import type ContentState from "./ContentState";
  16. import type { DraftBlockType } from "./DraftBlockType";
  17. import type { DraftEditorCommand } from "./DraftEditorCommand";
  18. import type { DataObjectForLink, RichTextUtils } from "./RichTextUtils";
  19. import type SelectionState from "./SelectionState";
  20. import type URI from "fbjs/lib/URI";
  21. const ContentBlockNode = require("./ContentBlockNode");
  22. const DraftModifier = require("./DraftModifier");
  23. const DraftTreeOperations = require("./DraftTreeOperations");
  24. const EditorState = require("./EditorState");
  25. const RichTextEditorUtil = require("./RichTextEditorUtil");
  26. const adjustBlockDepthForContentState = require("./adjustBlockDepthForContentState");
  27. const generateRandomKey = require("./generateRandomKey");
  28. const invariant = require("fbjs/lib/invariant"); // Eventually we could allow to control this list by either allowing user configuration
  29. // and/or a schema in conjunction to DraftBlockRenderMap
  30. const NESTING_DISABLED_TYPES = ['code-block', 'atomic'];
  31. const NestedRichTextEditorUtil: RichTextUtils = {
  32. handleKeyCommand: (editorState: EditorState, command: DraftEditorCommand | string): ?EditorState => {
  33. switch (command) {
  34. case 'bold':
  35. return NestedRichTextEditorUtil.toggleInlineStyle(editorState, 'BOLD');
  36. case 'italic':
  37. return NestedRichTextEditorUtil.toggleInlineStyle(editorState, 'ITALIC');
  38. case 'underline':
  39. return NestedRichTextEditorUtil.toggleInlineStyle(editorState, 'UNDERLINE');
  40. case 'code':
  41. return NestedRichTextEditorUtil.toggleCode(editorState);
  42. case 'backspace':
  43. case 'backspace-word':
  44. case 'backspace-to-start-of-line':
  45. return NestedRichTextEditorUtil.onBackspace(editorState);
  46. case 'delete':
  47. case 'delete-word':
  48. case 'delete-to-end-of-block':
  49. return NestedRichTextEditorUtil.onDelete(editorState);
  50. default:
  51. // they may have custom editor commands; ignore those
  52. return null;
  53. }
  54. },
  55. onDelete: (editorState: EditorState): ?EditorState => {
  56. const selection = editorState.getSelection();
  57. if (!selection.isCollapsed()) {
  58. return null;
  59. }
  60. const content = editorState.getCurrentContent();
  61. const startKey = selection.getStartKey();
  62. const block = content.getBlockForKey(startKey);
  63. const length = block.getLength(); // The cursor is somewhere within the text. Behave normally.
  64. if (selection.getStartOffset() < length) {
  65. return null;
  66. }
  67. const blockAfter = content.getBlockAfter(startKey);
  68. if (!blockAfter || blockAfter.getType() !== 'atomic') {
  69. return null;
  70. }
  71. const atomicBlockTarget = selection.merge({
  72. focusKey: blockAfter.getKey(),
  73. focusOffset: blockAfter.getLength()
  74. });
  75. const withoutAtomicBlock = DraftModifier.removeRange(content, atomicBlockTarget, 'forward');
  76. if (withoutAtomicBlock !== content) {
  77. return EditorState.push(editorState, withoutAtomicBlock, 'remove-range');
  78. }
  79. return null;
  80. },
  81. /**
  82. * Ensures that if on the beginning of unstyled block and first child of
  83. * a nested parent we add its text to the neareast previous leaf node
  84. */
  85. onBackspace: (editorState: EditorState): ?EditorState => {
  86. const selection = editorState.getSelection();
  87. const content = editorState.getCurrentContent();
  88. const currentBlock = content.getBlockForKey(selection.getStartKey());
  89. const previousBlockKey = currentBlock.getPrevSiblingKey();
  90. if (!selection.isCollapsed() || selection.getAnchorOffset() || selection.getFocusOffset() || currentBlock.getType() === 'unstyled' && previousBlockKey && content.getBlockForKey(previousBlockKey).getType() !== 'atomic') {
  91. return null;
  92. }
  93. const startKey = selection.getStartKey();
  94. const blockBefore = content.getBlockBefore(startKey); // we want to delete that block completely
  95. if (blockBefore && blockBefore.getType() === 'atomic') {
  96. const withoutAtomicBlock = DraftModifier.removeRange(content, selection.merge({
  97. focusKey: blockBefore.getKey(),
  98. focusOffset: blockBefore.getText().length,
  99. anchorKey: startKey,
  100. anchorOffset: content.getBlockForKey(startKey).getText().length,
  101. isBackward: false
  102. }), 'forward').merge({
  103. selectionAfter: selection
  104. });
  105. if (withoutAtomicBlock !== content) {
  106. return EditorState.push(editorState, withoutAtomicBlock, 'remove-range');
  107. }
  108. } // if we have a next sibbling we should not allow the normal backspace
  109. // behaviour of moving this text into its parent
  110. // if (currentBlock.getPrevSiblingKey()) {
  111. // return editorState;
  112. // }
  113. // If that doesn't succeed, try to remove the current block style.
  114. const withoutBlockStyle = NestedRichTextEditorUtil.tryToRemoveBlockStyle(editorState);
  115. if (withoutBlockStyle) {
  116. return EditorState.push(editorState, withoutBlockStyle, withoutBlockStyle.getBlockMap().get(currentBlock.getKey()).getType() === 'unstyled' ? 'change-block-type' : 'adjust-depth');
  117. }
  118. return null;
  119. },
  120. // Todo (T32099101)
  121. // onSplitNestedBlock() {},
  122. // Todo (T32099101)
  123. // onSplitParent() {},
  124. /**
  125. * Ensures that we can create nested blocks by changing the block type of
  126. * a ranged selection
  127. */
  128. toggleBlockType: (editorState: EditorState, blockType: DraftBlockType): EditorState => {
  129. const selection = editorState.getSelection();
  130. const content = editorState.getCurrentContent();
  131. const currentBlock = content.getBlockForKey(selection.getStartKey());
  132. const haveChildren = !currentBlock.getChildKeys().isEmpty();
  133. const isSelectionCollapsed = selection.isCollapsed();
  134. const isMultiBlockSelection = selection.getAnchorKey() !== selection.getFocusKey();
  135. const isUnsupportedNestingBlockType = NESTING_DISABLED_TYPES.includes(blockType);
  136. const isCurrentBlockOfUnsupportedNestingBlockType = NESTING_DISABLED_TYPES.includes(currentBlock.getType()); // we don't allow this operations to avoid corrupting the document data model
  137. // to make sure that non nested blockTypes wont inherit children
  138. if ((isMultiBlockSelection || haveChildren) && isUnsupportedNestingBlockType) {
  139. return editorState;
  140. } // we can treat this operations the same way as we would for flat data structures
  141. if (isCurrentBlockOfUnsupportedNestingBlockType || isSelectionCollapsed || isUnsupportedNestingBlockType || isMultiBlockSelection || currentBlock.getType() === blockType || !currentBlock.getChildKeys().isEmpty()) {
  142. return RichTextEditorUtil.toggleBlockType(editorState, blockType);
  143. } // TODO
  144. // if we have full range selection on the block:
  145. // extract text and insert a block after it with the text as its content
  146. // else
  147. // split the block into before range and after unstyled blocks
  148. //
  149. return editorState;
  150. },
  151. currentBlockContainsLink: (editorState: EditorState): boolean => {
  152. const selection = editorState.getSelection();
  153. const contentState = editorState.getCurrentContent();
  154. const entityMap = contentState.getEntityMap();
  155. return contentState.getBlockForKey(selection.getAnchorKey()).getCharacterList().slice(selection.getStartOffset(), selection.getEndOffset()).some(v => {
  156. const entity = v.getEntity();
  157. return !!entity && entityMap.__get(entity).getType() === 'LINK';
  158. });
  159. },
  160. getCurrentBlockType: (editorState: EditorState): DraftBlockType => {
  161. const selection = editorState.getSelection();
  162. return editorState.getCurrentContent().getBlockForKey(selection.getStartKey()).getType();
  163. },
  164. getDataObjectForLinkURL: (uri: URI): DataObjectForLink => {
  165. return {
  166. url: uri.toString()
  167. };
  168. },
  169. insertSoftNewline: (editorState: EditorState): EditorState => {
  170. const contentState = DraftModifier.insertText(editorState.getCurrentContent(), editorState.getSelection(), '\n', editorState.getCurrentInlineStyle(), null);
  171. const newEditorState = EditorState.push(editorState, contentState, 'insert-characters');
  172. return EditorState.forceSelection(newEditorState, contentState.getSelectionAfter());
  173. },
  174. onTab: (event: SyntheticKeyboardEvent<>, editorState: EditorState, maxDepth: number): EditorState => {
  175. const selection = editorState.getSelection();
  176. const key = selection.getAnchorKey();
  177. if (key !== selection.getFocusKey()) {
  178. return editorState;
  179. }
  180. let content = editorState.getCurrentContent();
  181. const block = content.getBlockForKey(key);
  182. const type = block.getType();
  183. if (type !== 'unordered-list-item' && type !== 'ordered-list-item') {
  184. return editorState;
  185. }
  186. event.preventDefault();
  187. const depth = block.getDepth();
  188. if (!event.shiftKey && depth === maxDepth) {
  189. return editorState;
  190. } // implement nested tree behaviour for onTab
  191. let blockMap = editorState.getCurrentContent().getBlockMap();
  192. const prevSiblingKey = block.getPrevSiblingKey();
  193. const nextSiblingKey = block.getNextSiblingKey();
  194. if (!event.shiftKey) {
  195. // if there is no previous sibling, we do nothing
  196. if (prevSiblingKey == null) {
  197. return editorState;
  198. } // if previous sibling is a non-leaf move node as child of previous sibling
  199. const prevSibling = blockMap.get(prevSiblingKey);
  200. const nextSibling = nextSiblingKey != null ? blockMap.get(nextSiblingKey) : null;
  201. const prevSiblingNonLeaf = prevSibling != null && prevSibling.getChildKeys().count() > 0;
  202. const nextSiblingNonLeaf = nextSibling != null && nextSibling.getChildKeys().count() > 0;
  203. if (prevSiblingNonLeaf) {
  204. blockMap = DraftTreeOperations.updateAsSiblingsChild(blockMap, key, 'previous'); // if next sibling is also non-leaf, merge the previous & next siblings
  205. if (nextSiblingNonLeaf) {
  206. blockMap = DraftTreeOperations.mergeBlocks(blockMap, prevSiblingKey);
  207. } // else, if only next sibling is non-leaf move node as child of next sibling
  208. } else if (nextSiblingNonLeaf) {
  209. blockMap = DraftTreeOperations.updateAsSiblingsChild(blockMap, key, 'next'); // if none of the siblings are non-leaf, we need to create a new parent
  210. } else {
  211. blockMap = DraftTreeOperations.createNewParent(blockMap, key);
  212. } // on un-tab
  213. } else {
  214. // if the block isn't nested, do nothing
  215. if (block.getParentKey() == null) {
  216. return editorState;
  217. }
  218. blockMap = onUntab(blockMap, block);
  219. }
  220. content = editorState.getCurrentContent().merge({
  221. blockMap: blockMap
  222. });
  223. const withAdjustment = adjustBlockDepthForContentState(content, selection, event.shiftKey ? -1 : 1, maxDepth);
  224. return EditorState.push(editorState, withAdjustment, 'adjust-depth');
  225. },
  226. toggleCode: (editorState: EditorState): EditorState => {
  227. const selection = editorState.getSelection();
  228. const anchorKey = selection.getAnchorKey();
  229. const focusKey = selection.getFocusKey();
  230. if (selection.isCollapsed() || anchorKey !== focusKey) {
  231. return RichTextEditorUtil.toggleBlockType(editorState, 'code-block');
  232. }
  233. return RichTextEditorUtil.toggleInlineStyle(editorState, 'CODE');
  234. },
  235. /**
  236. * Toggle the specified inline style for the selection. If the
  237. * user's selection is collapsed, apply or remove the style for the
  238. * internal state. If it is not collapsed, apply the change directly
  239. * to the document state.
  240. */
  241. toggleInlineStyle: (editorState: EditorState, inlineStyle: string): EditorState => {
  242. const selection = editorState.getSelection();
  243. const currentStyle = editorState.getCurrentInlineStyle(); // If the selection is collapsed, toggle the specified style on or off and
  244. // set the result as the new inline style override. This will then be
  245. // used as the inline style for the next character to be inserted.
  246. if (selection.isCollapsed()) {
  247. return EditorState.setInlineStyleOverride(editorState, currentStyle.has(inlineStyle) ? currentStyle.remove(inlineStyle) : currentStyle.add(inlineStyle));
  248. } // If characters are selected, immediately apply or remove the
  249. // inline style on the document state itself.
  250. const content = editorState.getCurrentContent();
  251. let newContent; // If the style is already present for the selection range, remove it.
  252. // Otherwise, apply it.
  253. if (currentStyle.has(inlineStyle)) {
  254. newContent = DraftModifier.removeInlineStyle(content, selection, inlineStyle);
  255. } else {
  256. newContent = DraftModifier.applyInlineStyle(content, selection, inlineStyle);
  257. }
  258. return EditorState.push(editorState, newContent, 'change-inline-style');
  259. },
  260. toggleLink: (editorState: EditorState, targetSelection: SelectionState, entityKey: ?string): EditorState => {
  261. const withoutLink = DraftModifier.applyEntity(editorState.getCurrentContent(), targetSelection, entityKey);
  262. return EditorState.push(editorState, withoutLink, 'apply-entity');
  263. },
  264. /**
  265. * When a collapsed cursor is at the start of a styled block, changes block
  266. * type to 'unstyled'. Returns null if selection does not meet that criteria.
  267. */
  268. tryToRemoveBlockStyle: (editorState: EditorState): ?ContentState => {
  269. const selection = editorState.getSelection();
  270. const offset = selection.getAnchorOffset();
  271. if (selection.isCollapsed() && offset === 0) {
  272. const key = selection.getAnchorKey();
  273. const content = editorState.getCurrentContent();
  274. const block = content.getBlockForKey(key);
  275. const type = block.getType();
  276. const blockBefore = content.getBlockBefore(key);
  277. if (type === 'code-block' && blockBefore && blockBefore.getType() === 'code-block' && blockBefore.getLength() !== 0) {
  278. return null;
  279. }
  280. const depth = block.getDepth();
  281. if (type !== 'unstyled') {
  282. if ((type === 'unordered-list-item' || type === 'ordered-list-item') && depth > 0) {
  283. let newBlockMap = onUntab(content.getBlockMap(), block);
  284. newBlockMap = newBlockMap.set(key, newBlockMap.get(key).merge({
  285. depth: depth - 1
  286. }));
  287. return content.merge({
  288. blockMap: newBlockMap
  289. });
  290. }
  291. return DraftModifier.setBlockType(content, selection, 'unstyled');
  292. }
  293. }
  294. return null;
  295. }
  296. };
  297. const onUntab = (blockMap: BlockMap, block: ContentBlockNode): BlockMap => {
  298. const key = block.getKey();
  299. const parentKey = block.getParentKey();
  300. const nextSiblingKey = block.getNextSiblingKey();
  301. if (parentKey == null) {
  302. return blockMap;
  303. }
  304. const parent = blockMap.get(parentKey);
  305. const existingChildren = parent.getChildKeys();
  306. const blockIndex = existingChildren.indexOf(key);
  307. if (blockIndex === 0 || blockIndex === existingChildren.count() - 1) {
  308. blockMap = DraftTreeOperations.moveChildUp(blockMap, key);
  309. } else {
  310. // split the block into [0, blockIndex] in parent & the rest in a new block
  311. const prevChildren = existingChildren.slice(0, blockIndex + 1);
  312. const nextChildren = existingChildren.slice(blockIndex + 1);
  313. blockMap = blockMap.set(parentKey, parent.merge({
  314. children: prevChildren
  315. }));
  316. const newBlock = new ContentBlockNode({
  317. key: generateRandomKey(),
  318. text: '',
  319. depth: parent.getDepth(),
  320. type: parent.getType(),
  321. children: nextChildren,
  322. parent: parent.getParentKey()
  323. }); // add new block just before its the original next sibling in the block map
  324. // TODO(T33894878): Remove the map reordering code & fix converter after launch
  325. invariant(nextSiblingKey != null, 'block must have a next sibling here');
  326. const blocks = blockMap.toSeq();
  327. blockMap = blocks.takeUntil(block => block.getKey() === nextSiblingKey).concat([[newBlock.getKey(), newBlock]], blocks.skipUntil(block => block.getKey() === nextSiblingKey)).toOrderedMap(); // set the nextChildren's parent to the new block
  328. blockMap = blockMap.map(block => nextChildren.includes(block.getKey()) ? block.merge({
  329. parent: newBlock.getKey()
  330. }) : block); // update the next/previous pointers for the children at the split
  331. blockMap = blockMap.set(key, block.merge({
  332. nextSibling: null
  333. })).set(nextSiblingKey, blockMap.get(nextSiblingKey).merge({
  334. prevSibling: null
  335. }));
  336. const parentNextSiblingKey = parent.getNextSiblingKey();
  337. if (parentNextSiblingKey != null) {
  338. blockMap = DraftTreeOperations.updateSibling(blockMap, newBlock.getKey(), parentNextSiblingKey);
  339. }
  340. blockMap = DraftTreeOperations.updateSibling(blockMap, parentKey, newBlock.getKey());
  341. blockMap = DraftTreeOperations.moveChildUp(blockMap, key);
  342. } // on untab, we also want to unnest any sibling blocks that become two levels deep
  343. // ensure that block's old parent does not have a non-leaf as its first child.
  344. let childWasUntabbed = false;
  345. if (parentKey != null) {
  346. let parent = blockMap.get(parentKey);
  347. while (parent != null) {
  348. const children = parent.getChildKeys();
  349. const firstChildKey = children.first();
  350. invariant(firstChildKey != null, 'parent must have at least one child');
  351. const firstChild = blockMap.get(firstChildKey);
  352. if (firstChild.getChildKeys().count() === 0) {
  353. break;
  354. } else {
  355. blockMap = DraftTreeOperations.moveChildUp(blockMap, firstChildKey);
  356. parent = blockMap.get(parentKey);
  357. childWasUntabbed = true;
  358. }
  359. }
  360. } // now, we may be in a state with two non-leaf blocks of the same type
  361. // next to each other
  362. if (childWasUntabbed && parentKey != null) {
  363. const parent = blockMap.get(parentKey);
  364. const prevSiblingKey = parent != null // parent may have been deleted
  365. ? parent.getPrevSiblingKey() : null;
  366. if (prevSiblingKey != null && parent.getChildKeys().count() > 0) {
  367. const prevSibling = blockMap.get(prevSiblingKey);
  368. if (prevSibling != null && prevSibling.getChildKeys().count() > 0) {
  369. blockMap = DraftTreeOperations.mergeBlocks(blockMap, prevSiblingKey);
  370. }
  371. }
  372. }
  373. return blockMap;
  374. };
  375. module.exports = NestedRichTextEditorUtil;