From 94c0e116ffebcdc7aba511a76b15b3e328e69655 Mon Sep 17 00:00:00 2001 From: Denis Oblogin Date: Sat, 6 Jan 2018 04:07:50 +0200 Subject: [PATCH] Fixed issue #1454 Fixed issue #1434 --- .../handlers/drag/DraftEditorDragHandler.js | 11 +- .../__tests__/DraftEditorDragHandler.test.js | 120 +++++++ .../DraftEditorDragHandler.test.js.snap | 310 ++++++++++++++++++ src/model/immutable/SelectionState.js | 169 +++++++++- src/model/modifier/DraftModifier.js | 23 +- 5 files changed, 629 insertions(+), 4 deletions(-) create mode 100644 src/component/handlers/drag/__tests__/DraftEditorDragHandler.test.js create mode 100644 src/component/handlers/drag/__tests__/__snapshots__/DraftEditorDragHandler.test.js.snap diff --git a/src/component/handlers/drag/DraftEditorDragHandler.js b/src/component/handlers/drag/DraftEditorDragHandler.js index ea2c01d763..c65556c6df 100644 --- a/src/component/handlers/drag/DraftEditorDragHandler.js +++ b/src/component/handlers/drag/DraftEditorDragHandler.js @@ -137,7 +137,16 @@ function moveText( editorState.getSelection(), targetSelection, ); - return EditorState.push(editorState, newContentState, 'insert-fragment'); + editorState = EditorState.push( + editorState, + newContentState, + 'insert-fragment', + ); + editorState = EditorState.forceSelection( + editorState, + newContentState.getSelectionAfter(), + ); + return editorState; } /** diff --git a/src/component/handlers/drag/__tests__/DraftEditorDragHandler.test.js b/src/component/handlers/drag/__tests__/DraftEditorDragHandler.test.js new file mode 100644 index 0000000000..829be377e2 --- /dev/null +++ b/src/component/handlers/drag/__tests__/DraftEditorDragHandler.test.js @@ -0,0 +1,120 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * 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. + * + * @emails oncall+draft_js + * @format + */ + +'use strict'; + +jest.disableAutomock(); +jest.mock('generateRandomKey'); + +const DraftEditorDragHandler = require('DraftEditorDragHandler'); +const EditorState = require('EditorState'); +const SelectionState = require('SelectionState'); +const getSampleSelectionMocksForTesting = require('getSampleSelectionMocksForTesting'); +const editOnDragStart = require('editOnDragStart'); + +let editorState = null; +let leafChildren = null; +let textNodes = null; + +// sample text: +// WashingtonJefferson +// LincolnRoosevelt +// KennedyObama +const resetRootNodeMocks = () => { + ({ + editorState, + leafChildren, + textNodes, + } = getSampleSelectionMocksForTesting()); + editorState = EditorState.acceptSelection( + editorState, + new SelectionState(RESET_SELECTION), + ); +}; + +const RESET_SELECTION = { + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: 0, + isBackward: false, +}; + +beforeEach(() => { + resetRootNodeMocks(); +}); + +test('drag-n-drop should be done correctly', () => { + //second word from line 2 + first word from line 3 ("Roosevelt\nKennedy") + const selectionRooseveltKennedy = { + anchorKey: 'b', + anchorOffset: textNodes[2].nodeValue.length, + focusKey: 'c', + focusOffset: textNodes[4].nodeValue.length, + isBackward: false, + }; + + const selectionStateRooseveltKennedy = new SelectionState( + selectionRooseveltKennedy, + ); + + const getDropEvent_RooseveltKennedy_toEnd = () => ({ + nativeEvent: { + // drop position is end of last word ("Obama") + rangeParent: leafChildren[leafChildren.length - 1], + rangeOffset: textNodes[textNodes.length - 1].nodeValue.length, + // data contains text "Roosevelt\nKennedy" + dataTransfer: { + getData: () => { + return textNodes[3].nodeValue + '\n' + textNodes[4].nodeValue; + }, + }, + }, + preventDefault: jest.fn(), + }); + + const editorStateRooseveltKennedy = EditorState.acceptSelection( + editorState, + selectionStateRooseveltKennedy, + ); + + const editor = { + _latestEditorState: editorStateRooseveltKennedy, + props: {}, + update: jest.fn(), + exitCurrentMode: jest.fn(), + setMode: jest.fn(), + nodeType: 1, //ELEMENT_NODE + dispatchEvent: jest.fn(), + }; + + expect(() => { + editOnDragStart(editor); + DraftEditorDragHandler.onDrop( + editor, + getDropEvent_RooseveltKennedy_toEnd(), + ); + }).not.toThrow(); + + // result should be: + // WashingtonJefferson + // LincolnObamaRoosevelt + // Kennedy + expect(editor.setMode).toHaveBeenCalledTimes(1); + expect(editor.exitCurrentMode).toHaveBeenCalledTimes(1); + expect(editor.update).toHaveBeenCalledTimes(1); + + const newEditorState = editor.update.mock.calls[0][0]; + expect(newEditorState.getSelection().toJS()).toMatchSnapshot(); + expect(newEditorState.getCurrentContent().toJS()).toMatchSnapshot(); +}); diff --git a/src/component/handlers/drag/__tests__/__snapshots__/DraftEditorDragHandler.test.js.snap b/src/component/handlers/drag/__tests__/__snapshots__/DraftEditorDragHandler.test.js.snap new file mode 100644 index 0000000000..9147b01ee0 --- /dev/null +++ b/src/component/handlers/drag/__tests__/__snapshots__/DraftEditorDragHandler.test.js.snap @@ -0,0 +1,310 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`drag-n-drop should be done correctly 1`] = ` +Object { + "anchorKey": "b", + "anchorOffset": 12, + "focusKey": "b", + "focusOffset": 12, + "hasFocus": true, + "isBackward": false, +} +`; + +exports[`drag-n-drop should be done correctly 2`] = ` +Object { + "blockMap": Object { + "a": Object { + "characterList": Array [ + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + ], + "data": Object {}, + "depth": 0, + "key": "a", + "text": "WashingtonJefferson", + "type": "unstyled", + }, + "b": Object { + "characterList": Array [ + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + Object { + "entity": null, + "style": Array [ + "BOLD", + ], + }, + ], + "data": Object {}, + "depth": 0, + "key": "b", + "text": "LincolnObamaRoosevelt", + "type": "unstyled", + }, + "key3": Object { + "characterList": Array [ + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + Object { + "entity": null, + "style": Array [], + }, + ], + "data": Object {}, + "depth": 0, + "key": "key3", + "text": "Kennedy", + "type": "unstyled", + }, + }, + "entityMap": Object { + "__add": [Function], + "__create": [Function], + "__get": [Function], + "__getLastCreatedEntityKey": [Function], + "__mergeData": [Function], + "__replaceData": [Function], + "add": [Function], + "create": [Function], + "get": [Function], + "getLastCreatedEntityKey": [Function], + "mergeData": [Function], + "replaceData": [Function], + }, + "selectionAfter": Object { + "anchorKey": "b", + "anchorOffset": 12, + "focusKey": "b", + "focusOffset": 12, + "hasFocus": false, + "isBackward": false, + }, + "selectionBefore": Object { + "anchorKey": "b", + "anchorOffset": 7, + "focusKey": "c", + "focusOffset": 7, + "hasFocus": false, + "isBackward": false, + }, +} +`; diff --git a/src/model/immutable/SelectionState.js b/src/model/immutable/SelectionState.js index d8124da197..93bd184c8d 100644 --- a/src/model/immutable/SelectionState.js +++ b/src/model/immutable/SelectionState.js @@ -13,8 +13,31 @@ 'use strict'; -const Immutable = require('immutable'); +const SelectionsCompareResultKeys = { + OVERLAP: true, + INSIDE: true, + OUTSIDE: true, + LEFT: true, + RIGHT: true, + EQUAL: true, + UNKNOWN: true, +}; +/** + * An enum representing the compare result between two selections. + * + * 'OVERLAP' - selections overlaps + * 'INSIDE' - current selection in inside of another selection + * 'OUTSIDE' - current selection in outside of another selection + * 'LEFT' - current selection is before another + * 'RIGHT' - current selection is after another + * 'EQUAL' - selections are equal + * 'UNKNOWN' - can't compare + */ +type SelectionsCompareResult = $Keys; +type Position = {key: string, offset: number}; +import type {BlockMap} from 'BlockMap'; +const Immutable = require('immutable'); const {Record} = Immutable; const defaultRecord: { @@ -144,6 +167,150 @@ class SelectionState extends SelectionStateRecord { hasFocus: false, }); } + + /** + * Compare current selection with another selection + */ + compareWithSelection( + sel2: SelectionState, + blockMap: BlockMap, + ): SelectionsCompareResult { + let sel1 = this; + let s1Start = {key: sel1.getStartKey(), offset: sel1.getStartOffset()}; + let s1End = {key: sel1.getEndKey(), offset: sel1.getEndOffset()}; + let s2Start = {key: sel2.getStartKey(), offset: sel2.getStartOffset()}; + let s2End = {key: sel2.getEndKey(), offset: sel2.getEndOffset()}; + + let isOkDirection = + comparePositions(s1Start, s1End, blockMap) <= 0 && + comparePositions(s2Start, s2End, blockMap) <= 0; + let areEqual = + s1Start.key == s2Start.key && + s1Start.offset == s2Start.offset && + s1End.key == s2End.key && + s1End.offset == s2End.offset; + + let s1Start_to_s2Start = comparePositions(s1Start, s2Start, blockMap); + let s1End_to_s2Start = comparePositions(s1End, s2Start, blockMap); + let s1Start_to_s2End = comparePositions(s1Start, s2End, blockMap); + let s1End_to_s2End = comparePositions(s1End, s2End, blockMap); + + let isOverlap = + (s1Start_to_s2Start > 0 && s1Start_to_s2End < 0 && s1End_to_s2End > 0) || + (s1Start_to_s2Start < 0 && s1End_to_s2Start > 0 && s1End_to_s2End < 0); + let isS1Outside = s1Start_to_s2Start <= 0 && s1End_to_s2End >= 0; + let isS1Inside = s1Start_to_s2Start >= 0 && s1End_to_s2End <= 0; + let isS1ToRight = s1Start_to_s2End >= 0; + let isS1ToLeft = s1End_to_s2Start <= 0; + + let rel = !isOkDirection + ? 'UNKNOWN' + : areEqual + ? 'EQUAL' + : isOverlap + ? 'OVERLAP' + : isS1Outside + ? 'OUTSIDE' + : isS1Inside + ? 'INSIDE' + : isS1ToRight + ? 'RIGHT' + : isS1ToLeft + ? 'LEFT' + : 'UNKNOWN'; + + return rel; + } + + /** + * Fix current selection after deletion of selection `selDel` + */ + updateOnDeletingSelection( + selDel: SelectionState, + blockMap: BlockMap, + ): SelectionState { + let startKey = this.getStartKey(); + let startOffset = this.getStartOffset(); + let endKey = this.getEndKey(); + let endOffset = this.getEndOffset(); + + let newStartPos = fixPosOnDeletingSelection( + {key: startKey, offset: startOffset}, + selDel, + blockMap, + ); + startKey = newStartPos.key; + startOffset = newStartPos.offset; + let newEndPos = fixPosOnDeletingSelection( + {key: endKey, offset: endOffset}, + selDel, + blockMap, + ); + endKey = newEndPos.key; + endOffset = newEndPos.offset; + + let selection = SelectionState.createEmpty(startKey).merge({ + anchorKey: startKey, + anchorOffset: startOffset, + focusKey: endKey, + focusOffset: endOffset, + }); + return selection; + } +} + +function fixPosOnDeletingSelection( + pos: Position, + selDel: SelectionState, + blockMap: BlockMap, +): Position { + pos = Object.assign({}, pos); + let isSelectionOnSingleBlock = selDel.getStartKey() == selDel.getEndKey(); + let cmp = comparePositionWithSelection(pos, selDel, blockMap); + if (cmp == 'INSIDE') { + //position is inside of selection to delete + pos.key = selDel.getStartKey(); + pos.offset = selDel.getStartOffset(); + } else if (cmp == 'LEFT') { + //position is before selection to delete + //safe + } else if (cmp == 'RIGHT') { + //position is after selection to delete + if (isSelectionOnSingleBlock) { + if (pos.key == selDel.getEndKey()) { + pos.offset -= selDel.getEndOffset() - selDel.getStartOffset(); + } + } else if (pos.key == selDel.getEndKey()) { + pos.key = selDel.getStartKey(); + pos.offset = selDel.getStartOffset() + pos.offset - selDel.getEndOffset(); + } + } + + return pos; +} + +function comparePositionWithSelection( + pos1: Position, + sel2: SelectionState, + blockMap: BlockMap, +): SelectionsCompareResult { + let sel1 = SelectionState.createEmpty(pos1.key).merge({ + anchorKey: pos1.key, + anchorOffset: pos1.offset, + focusKey: pos1.key, + focusOffset: pos1.offset, + }); + return sel1.compareWithSelection(sel2, blockMap); +} + +function comparePositions( + pos1: Position, + pos2: Position, + blockMap: BlockMap, +): number { + let ind1 = blockMap.keySeq().findIndex(k => k == pos1.key); + let ind2 = blockMap.keySeq().findIndex(k => k == pos2.key); + return ind1 == ind2 ? pos1.offset - pos2.offset : ind1 - ind2; } module.exports = SelectionState; diff --git a/src/model/modifier/DraftModifier.js b/src/model/modifier/DraftModifier.js index 94f25d40f7..28d02977e8 100644 --- a/src/model/modifier/DraftModifier.js +++ b/src/model/modifier/DraftModifier.js @@ -18,13 +18,13 @@ import type ContentState from 'ContentState'; import type {DraftBlockType} from 'DraftBlockType'; import type {DraftInlineStyle} from 'DraftInlineStyle'; import type {DraftRemovalDirection} from 'DraftRemovalDirection'; -import type SelectionState from 'SelectionState'; import type {Map} from 'immutable'; const CharacterMetadata = require('CharacterMetadata'); const ContentStateInlineStyle = require('ContentStateInlineStyle'); const Immutable = require('immutable'); +const SelectionState = require('SelectionState'); const applyEntityToContentState = require('applyEntityToContentState'); const getCharacterRemovalRange = require('getCharacterRemovalRange'); const getContentStateFragment = require('getContentStateFragment'); @@ -109,11 +109,30 @@ const DraftModifier = { 'backward', ); - return DraftModifier.replaceWithFragment( + let selBlockMap = contentState.getBlockMap(); + targetRange = targetRange.updateOnDeletingSelection( + removalRange, + selBlockMap, + ); + + let afterReplaced = DraftModifier.replaceWithFragment( afterRemoval, targetRange, movedFragment, ); + + let selectionAfter = SelectionState.createEmpty( + targetRange.getStartKey(), + ).merge({ + anchorKey: targetRange.getStartKey(), + anchorOffset: targetRange.getStartOffset(), + focusKey: targetRange.getStartKey(), + focusOffset: targetRange.getStartOffset(), + }); + + afterReplaced = afterReplaced.merge({selectionAfter: selectionAfter}); + + return afterReplaced; }, replaceWithFragment: function(