Skip to content
This repository has been archived by the owner on Feb 6, 2023. It is now read-only.

Close a race after handling before input #1609

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/component/base/DraftEditor.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {DraftEditorModes} from 'DraftEditorModes';
import type {DraftEditorDefaultProps, DraftEditorProps} from 'DraftEditorProps';
import type {DraftScrollPosition} from 'DraftScrollPosition';

const ContentBlock = require('ContentBlock');
const DefaultDraftBlockRenderMap = require('DefaultDraftBlockRenderMap');
const DefaultDraftInlineStyle = require('DefaultDraftInlineStyle');
const DraftEditorCompositionHandler = require('DraftEditorCompositionHandler');
Expand Down Expand Up @@ -153,8 +154,9 @@ class DraftEditor extends React.Component<DraftEditorProps, State> {
_editorKey: string;
_placeholderAccessibilityID: string;
_latestEditorState: EditorState;
_renderNativeContent: boolean;
_updatedNativeInsertionBlock: ?ContentBlock;
_latestCommittedEditorState: EditorState;
_pendingStateFromBeforeInput: void | EditorState;

/**
* Define proxies that can route events to the current handler.
Expand Down Expand Up @@ -563,7 +565,11 @@ class DraftEditor extends React.Component<DraftEditorProps, State> {
* an `onChange` prop to receive state updates passed along from this
* function.
*/
update = (editorState: EditorState): void => {
update = (
editorState: EditorState,
renderNativeContent: boolean = false,
): void => {
this._renderNativeContent = renderNativeContent;
this._latestEditorState = editorState;
this.props.onChange(editorState);
};
Expand Down
52 changes: 38 additions & 14 deletions src/component/handlers/edit/editOnBeforeInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const isEventHandled = require('isEventHandled');
const isSelectionAtLeafStart = require('isSelectionAtLeafStart');
const nullthrows = require('nullthrows');
const setImmediate = require('setImmediate');
const editOnInput = require('editOnInput');
const editOnSelect = require('editOnSelect');

// When nothing is focused, Firefox regards two characters, `'` and `/`, as
// commands that should open and focus the "quickfind" search bar. This should
Expand All @@ -35,6 +37,7 @@ const setImmediate = require('setImmediate');
const FF_QUICKFIND_CHAR = "'";
const FF_QUICKFIND_LINK_CHAR = '/';
const isFirefox = UserAgent.isBrowser('Firefox');
const isIE = UserAgent.isBrowser('IE');

function mustPreventDefaultForCharacter(character: string): boolean {
return (
Expand Down Expand Up @@ -76,10 +79,10 @@ function editOnBeforeInput(
editor: DraftEditor,
e: SyntheticInputEvent<>,
): void {
if (editor._pendingStateFromBeforeInput !== undefined) {
editor.update(editor._pendingStateFromBeforeInput);
editor._pendingStateFromBeforeInput = undefined;
}
// React doesn't fire a selection event until mouseUp, so it's possible to
// click to change selection, hold the mouse down, and type a character
// without React registering it. Let's sync the selection manually now.
editOnSelect(editor);

const editorState = editor._latestEditorState;

Expand Down Expand Up @@ -262,17 +265,38 @@ function editOnBeforeInput(
newEditorState = EditorState.set(newEditorState, {
nativelyRenderedContent: newEditorState.getCurrentContent(),
});
// The native event is allowed to occur. To allow user onChange handlers to
// change the inserted text, we wait until the text is actually inserted
// before we actually update our state. That way when we rerender, the text
// we see in the DOM will already have been inserted properly.
editor._pendingStateFromBeforeInput = newEditorState;
setImmediate(() => {
if (editor._pendingStateFromBeforeInput !== undefined) {
editor.update(editor._pendingStateFromBeforeInput);
editor._pendingStateFromBeforeInput = undefined;

editor._updatedNativeInsertionBlock = editorState
.getCurrentContent()
.getBlockForKey(editorState.getSelection().getAnchorKey());

// Allow the native insertion to occur and update our internal state to match.
// If editor.update() does something like changing a typed 'x' to 'abc' in an
// onChange() handler, we don't want our editOnInput() logic to squash that
// change in favor of the typed 'x'. Set a flag to ignore the next
// editOnInput() event in favor of what's in our internal state.
editor.update(newEditorState, true);

const editorStateAfterUpdate = editor._latestEditorState;
const contentStateAfterUpdate = editorStateAfterUpdate.getCurrentContent();
const expectedContentStateAfterUpdate = editorStateAfterUpdate.getNativelyRenderedContent();

if (
expectedContentStateAfterUpdate &&
expectedContentStateAfterUpdate === contentStateAfterUpdate
) {
if (isIE) {
setImmediate(() => {
editOnInput(editor);
});
}
});
} else {
// Outside callers (via the editor.onChange prop) have changed the
// editorState. No longer allow native insertion.
e.preventDefault();
editor._updatedNativeInsertionBlock = null;
editor._renderNativeContent = false;
}
}

module.exports = editOnBeforeInput;
95 changes: 50 additions & 45 deletions src/component/handlers/edit/editOnInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ import type DraftEditor from 'DraftEditor.react';
const DraftModifier = require('DraftModifier');
const DraftOffsetKey = require('DraftOffsetKey');
const EditorState = require('EditorState');
const UserAgent = require('UserAgent');

const editOnSelect = require('editOnSelect');
const EditorBidiService = require('EditorBidiService');

const findAncestorOffsetKey = require('findAncestorOffsetKey');
const gkx = require('gkx');
const nullthrows = require('nullthrows');

const isGecko = UserAgent.isEngine('Gecko');

const DOUBLE_NEWLINE = '\n\n';

/**
Expand All @@ -40,14 +40,49 @@ const DOUBLE_NEWLINE = '\n\n';
* due to a spellcheck change, and we can incorporate it into our model.
*/
function editOnInput(editor: DraftEditor): void {
if (editor._pendingStateFromBeforeInput !== undefined) {
editor.update(editor._pendingStateFromBeforeInput);
editor._pendingStateFromBeforeInput = undefined;
// We have already updated our internal state appropriately for this input
// event. See editOnBeforeInput() for more info
if (editor._updatedNativeInsertionBlock && !editor._renderNativeContent) {
editor._updatedNativeInsertionBlock = null;
return;
}

editOnSelect(editor);
let editorState = editor._latestEditorState;

if (editor._updatedNativeInsertionBlock) {
const oldBlock = editor._updatedNativeInsertionBlock;
if (editorState.getSelection().getFocusKey() !== oldBlock.getKey()) {
// The selection has changed between editOnBeforeInput and now, and our
// optimistically updated block is no longer valid.
// Replace it with the non-updated block and let the input fall through.
const currentContent = editorState.getCurrentContent();
const contentWithOldBlock = currentContent.merge({
blockMap: currentContent.getBlockMap().set(oldBlock.getKey(), oldBlock),
selectionBefore: currentContent.getSelectionBefore(),
selectionAfter: currentContent.getSelectionAfter(),
});

const directionMap = EditorBidiService.getDirectionMap(
contentWithOldBlock,
editorState.getDirectionMap(),
);

editor.update(
EditorState.set(editorState, {
currentContent: contentWithOldBlock,
directionMap,
}),
);

editorState = editor._latestEditorState;
}
editor._updatedNativeInsertionBlock = null;
}

const domSelection = global.getSelection();

const {anchorNode, isCollapsed} = domSelection;
const {anchorNode} = domSelection;
const isNotTextNode = anchorNode.nodeType !== Node.TEXT_NODE;
const isNotTextOrElementNode =
anchorNode.nodeType !== Node.TEXT_NODE &&
Expand Down Expand Up @@ -86,7 +121,6 @@ function editOnInput(editor: DraftEditor): void {
}

let domText = anchorNode.textContent;
const editorState = editor._latestEditorState;
const offsetKey = nullthrows(findAncestorOffsetKey(anchorNode));
const {blockKey, decoratorKey, leafKey} = DraftOffsetKey.decode(offsetKey);

Expand Down Expand Up @@ -135,50 +169,21 @@ function editOnInput(editor: DraftEditor): void {
// native browser undo.
const changeType = preserveEntity ? 'spellcheck-change' : 'apply-entity';

// Replace the full text of the leaf and set the selection to the value
// calculated in editOnSelect() above, because replacing the leaf will move
// the selection to the end of the leaf rather than the end of the changed
// text.
const newContent = DraftModifier.replaceText(
content,
targetRange,
domText,
block.getInlineStyleAt(start),
preserveEntity ? block.getEntityAt(start) : null,
);

let anchorOffset, focusOffset, startOffset, endOffset;

if (isGecko) {
// Firefox selection does not change while the context menu is open, so
// we preserve the anchor and focus values of the DOM selection.
anchorOffset = domSelection.anchorOffset;
focusOffset = domSelection.focusOffset;
startOffset = start + Math.min(anchorOffset, focusOffset);
endOffset = startOffset + Math.abs(anchorOffset - focusOffset);
anchorOffset = startOffset;
focusOffset = endOffset;
} else {
// Browsers other than Firefox may adjust DOM selection while the context
// menu is open, and Safari autocorrect is prone to providing an inaccurate
// DOM selection. Don't trust it. Instead, use our existing SelectionState
// and adjust it based on the number of characters changed during the
// mutation.
const charDelta = domText.length - modelText.length;
startOffset = selection.getStartOffset();
endOffset = selection.getEndOffset();

anchorOffset = isCollapsed ? endOffset + charDelta : startOffset;
focusOffset = endOffset + charDelta;
}

// Segmented entities are completely or partially removed when their
// text content changes. For this case we do not want any text to be selected
// after the change, so we are not merging the selection.
const contentWithAdjustedDOMSelection = newContent.merge({
selectionBefore: content.getSelectionAfter(),
selectionAfter: selection.merge({anchorOffset, focusOffset}),
});
)
.set('selectionBefore', content.getSelectionBefore())
.set('selectionAfter', content.getSelectionAfter());

editor.update(
EditorState.push(editorState, contentWithAdjustedDOMSelection, changeType),
);
editor.update(EditorState.push(editorState, newContent, changeType));
}

module.exports = editOnInput;