Skip to content

Commit

Permalink
Remove unnecessary DOM selection updates (#31)
Browse files Browse the repository at this point in the history
* Update build

* updates jest snapshots for latest jest format

* More conservative selection updating

Don't update the selection on a leaf if it only intersects the selection on the leaf's trailing edge and there are additional leaves in the block.

Before this change, in this case, we were clearing the DOM selection and adding a point to it only to immediately clear it again when the following leaf processed. In this case we do not need to update the DOM selection at all (it isn't changing) so this was 100% unnecessary overhead. It has a significant performance impact in long editor states.

* Update package.json

* Symmetric check for more conservative selection updates

This handles the opposite case: where the selection would be set on a node that is only on the trailing edge of the selection when there are already other leaves in the block within the selection. There's no reason that this should be added to the selection.

* v0.11.6-descript.19

* v0.11.6-descript.20

* v0.11.6-descript.21

* v0.11.6-descript.22

* Option to update or set DOM selection in a single place

* more cleanup

* re-use new function to get dom selection

* v0.11.6-descript.23

* fix doc

* better docs
  • Loading branch information
srubin authored Oct 16, 2023
1 parent 94894dd commit 75cebc9
Show file tree
Hide file tree
Showing 12 changed files with 485 additions and 50 deletions.
7 changes: 7 additions & 0 deletions .idea/prettier.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@descript/draft-js",
"description": "A React framework for building text editors.",
"version": "0.11.6-descript.17",
"version": "0.11.6-descript.23",
"keywords": [
"draftjs",
"editor",
Expand Down
2 changes: 2 additions & 0 deletions src/component/base/DraftEditor.react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ export default class DraftEditor extends React.Component<
scrollUpHeight,
scrollDownHeight,
scrollDownThreshold,
globalDomSelectionUpdate,
} = this.props;

const rootClass = cx({
Expand Down Expand Up @@ -386,6 +387,7 @@ export default class DraftEditor extends React.Component<
scrollUpHeight,
scrollDownThreshold,
scrollDownHeight,
globalDomSelectionUpdate,
};

return (
Expand Down
6 changes: 6 additions & 0 deletions src/component/base/DraftEditorProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ export type DraftEditorProps = {
draftEditor: DraftEditor,
syntheticClipboardEvent: SyntheticClipboardEvent,
) => void;

/**
* Enables support for experimental (more performant) strategy for
* updating DOM selection
*/
globalDomSelectionUpdate?: boolean;
};

export type DraftEditorDefaultProps = {
Expand Down
3 changes: 3 additions & 0 deletions src/component/contents/DraftEditorBlock.react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from '../../model/immutable/ContentBlock';
import {DraftDecoratorComponentProps} from '../../model/decorators/DraftDecorator';
import {BlockNode} from '../../model/immutable/BlockNode';
import {DOMSelectionUpdateFn} from '../selection/DOMSelectionUpdate';

const DEFAULT_SCROLL_BUFFER = 10;

Expand All @@ -63,6 +64,7 @@ type Props = {
scrollUpHeight?: number;
scrollDownThreshold?: number;
scrollDownHeight?: number;
scheduleDomSelectionUpdate?: DOMSelectionUpdateFn;
};

/**
Expand Down Expand Up @@ -302,6 +304,7 @@ export default class DraftEditorBlock extends React.Component<Props> {
customStyleMap={this.props.customStyleMap}
customStyleFn={this.props.customStyleFn}
isLast={ii === lastLeafSet && jj === lastLeaf}
scheduleDomSelectionUpdate={this.props.scheduleDomSelectionUpdate}
/>
);
});
Expand Down
67 changes: 66 additions & 1 deletion src/component/contents/DraftEditorContents-core.react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import {nullthrows} from '../../fbjs/nullthrows';
import DraftOffsetKey from '../selection/DraftOffsetKey';
import DraftEditorBlock from './DraftEditorBlock.react';
import {BlockNode} from '../../model/immutable/BlockNode';
import {
DOMLocation,
DOMSelectionUpdateFn,
updateDOMSelection,
} from '../selection/DOMSelectionUpdate';
import {getDOMSelection} from '../selection/DOMSelection';

type Props = {
blockRenderMap: DraftBlockRenderMap;
Expand All @@ -37,6 +43,11 @@ type Props = {
scrollUpHeight?: number;
scrollDownThreshold?: number;
scrollDownHeight?: number;
/**
* Enables support for experimental (more performant) strategy for
* updating DOM selection
*/
globalDomSelectionUpdate?: boolean;
};

/**
Expand Down Expand Up @@ -76,6 +87,12 @@ const getListItemClasses = (
* the contents of the editor.
*/
export default class DraftEditorContents extends React.Component<Props> {
private scheduledDomSelectionUpdates: {
anchor?: DOMLocation;
focus?: DOMLocation;
} = {anchor: undefined, focus: undefined};
private ref = React.createRef<HTMLDivElement>();

shouldComponentUpdate(nextProps: Props): boolean {
const prevEditorState = this.props.editorState;
const nextEditorState = nextProps.editorState;
Expand All @@ -99,6 +116,13 @@ export default class DraftEditorContents extends React.Component<Props> {
return true;
}

if (
Boolean(this.props.globalDomSelectionUpdate) !==
Boolean(nextProps.globalDomSelectionUpdate)
) {
return true;
}

const nextNativeContent = nextEditorState.nativelyRenderedContent;

const wasComposing = prevEditorState.inCompositionMode;
Expand Down Expand Up @@ -127,7 +151,41 @@ export default class DraftEditorContents extends React.Component<Props> {
);
}

componentDidUpdate() {
if (this.props.globalDomSelectionUpdate) {
this._updateDomSelection();
}
}

_updateDomSelection() {
const thisNode = this.ref.current;
if (thisNode) {
const selection = getDOMSelection(thisNode);
if (selection) {
updateDOMSelection(
selection,
this.scheduledDomSelectionUpdates.anchor,
this.scheduledDomSelectionUpdates.focus,
this.props.editorState.selection,
);
}
this._clearDomSelectionUpdates();
}
}

_clearDomSelectionUpdates() {
this.scheduledDomSelectionUpdates.anchor = undefined;
this.scheduledDomSelectionUpdates.focus = undefined;
}

scheduleDomSelectionUpdate: DOMSelectionUpdateFn = (type, loc) => {
this.scheduledDomSelectionUpdates[type] = loc;
};

render(): React.ReactNode {
// Reset the DOM selection updates before each render
this._clearDomSelectionUpdates();

const {
blockRenderMap,
blockRendererFn,
Expand Down Expand Up @@ -197,6 +255,9 @@ export default class DraftEditorContents extends React.Component<Props> {
scrollUpHeight,
scrollDownThreshold,
scrollDownHeight,
scheduleDomSelectionUpdate: this.props.globalDomSelectionUpdate
? this.scheduleDomSelectionUpdate
: undefined,
};

const configForType =
Expand Down Expand Up @@ -302,6 +363,10 @@ export default class DraftEditorContents extends React.Component<Props> {
}
}

return <div data-contents="true">{outputBlocks}</div>;
return (
<div data-contents="true" ref={this.ref}>
{outputBlocks}
</div>
);
}
}
49 changes: 47 additions & 2 deletions src/component/contents/DraftEditorLeaf.react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import React from 'react';
import {DraftInlineStyle} from '../../model/immutable/DraftInlineStyle';
import {
hasEdgeWithin,
isOnlyOnLeadingEdgeAndIsNotFirstSelectionInBlock,
isOnlyOnTrailingEdgeAndIsNotLastInBlock,
SelectionState,
} from '../../model/immutable/SelectionState';
import invariant from '../../fbjs/invariant';
import isHTMLBRElement from '../utils/isHTMLBRElement';
import {setDraftEditorSelection} from '../selection/setDraftEditorSelection';
import DraftEditorTextNode from './DraftEditorTextNode.react';
import {BlockNode} from '../../model/immutable/BlockNode';
import {DOMSelectionUpdateFn} from '../selection/DOMSelectionUpdate';

type CSSStyleObject = {[K in string]: string | number};

Expand Down Expand Up @@ -50,6 +53,7 @@ type Props = {
styleSet: DraftInlineStyle;
// The full text to be rendered within this node.
text: string;
scheduleDomSelectionUpdate?: DOMSelectionUpdateFn;
};

/**
Expand Down Expand Up @@ -85,7 +89,41 @@ export default class DraftEditorLeaf extends React.Component<Props> {
const {block, start, text} = this.props;
const blockKey = block.key;
const end = start + text.length;
if (!hasEdgeWithin(selection, blockKey, start, end)) {
if (
!hasEdgeWithin(selection, blockKey, start, end) ||
/**
* There are two ways to represent a selection point that falls on the
* boundary of two nodes:
* 1. The end of node `n`
* 2. The beginning of node `n+1`
*
* In order to keep selection consistent from one render to the next, we
* need to establish rules for how we decide how to represent a selection.
*
* There are multiple sets of rules that would work here. As long as we're
* consistent, we'll be ok (especially with the new global selection
* management mechanism, which ensures that only a single anchor and focus
* are updated per Editor render).
*
* Here are the rules for deciding how we represent each selection:
* 1. Bias toward condition #2 above when not adjusted by the other rules.
* 2. When there is no node `n+1` (covered by the `...isNotLastInBlock`)
* we need to represent the selection as the end of node `n`.
* 3. When the selection contains some of node `n` and ends at the end
* point of node `n`, we do not include node `n+1` in the selection.
*/
isOnlyOnTrailingEdgeAndIsNotLastInBlock(
selection,
blockKey,
end,
block.text.length,
) ||
isOnlyOnLeadingEdgeAndIsNotFirstSelectionInBlock(
selection,
blockKey,
start,
)
) {
return;
}

Expand All @@ -107,7 +145,14 @@ export default class DraftEditorLeaf extends React.Component<Props> {
invariant(targetNode, 'Missing targetNode');
}

setDraftEditorSelection(selection, targetNode!, blockKey, start, end);
setDraftEditorSelection(
selection,
targetNode!,
blockKey,
start,
end,
this.props.scheduleDomSelectionUpdate,
);
}

shouldComponentUpdate(nextProps: Props): boolean {
Expand Down
15 changes: 15 additions & 0 deletions src/component/selection/DOMSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {SelectionObject} from '../utils/DraftDOMTypes';
import getCorrectDocumentFromNode from '../utils/getCorrectDocumentFromNode';
import containsNode from 'fbjs/lib/containsNode';

export function getDOMSelection(node: Node): SelectionObject | undefined {
// It's possible that the editor has been removed from the DOM but
// our selection code doesn't know it yet. Forcing selection in
// this case may lead to errors, so just bail now.
const documentObject = getCorrectDocumentFromNode(node);
if (!containsNode(documentObject.documentElement, node)) {
return undefined;
}

return documentObject.defaultView!.getSelection() as SelectionObject;
}
66 changes: 66 additions & 0 deletions src/component/selection/DOMSelectionUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
addFocusToSelection,
addPointToSelection,
} from './setDraftEditorSelection';
import {SelectionObject} from '../utils/DraftDOMTypes';
import {SelectionState} from '../../model/immutable/SelectionState';

export type DOMLocation = {
node: Node;
offset: number;
};

export type DOMSelectionUpdateFn = (
type: 'anchor' | 'focus',
loc: DOMLocation,
) => void;

/**
* Modifies the DOM selection according to a new anchor and focus.
* This function attempts to perform a minimal update for performance
* reasons (i.e., it the selection hasn't changed, it will not update
* the selection; if only the focus has changed, it will not modify the
* anchor).
*/
export function updateDOMSelection(
domSelection: SelectionObject,
newAnchor: DOMLocation | undefined,
newFocus: DOMLocation | undefined,
draftSelection: SelectionState,
): void {
// if there's a missing focus or anchor, assume a point selection
newAnchor = newAnchor || newFocus;
newFocus = newFocus || newAnchor;
if (!newAnchor || !newFocus) {
// if neither, assume that a selection update was not needed
return;
}

const anchorChanged =
domSelection.anchorNode !== newAnchor.node ||
domSelection.anchorOffset !== newAnchor.offset;
const focusChanged =
domSelection.focusNode !== newFocus.node ||
domSelection.focusOffset !== newFocus.offset;

// only update the selection if it is not already correct
if (anchorChanged || focusChanged) {
if (anchorChanged) {
// start a selection from scratch
domSelection.removeAllRanges();
addPointToSelection(
domSelection,
newAnchor.node,
newAnchor.offset,
draftSelection,
);
}
// add the focus
addFocusToSelection(
domSelection,
newFocus.node,
newFocus.offset,
draftSelection,
);
}
}
Loading

0 comments on commit 75cebc9

Please sign in to comment.