facebookarchive / draft-js

A React framework for building text editors.
https://draftjs.org/
MIT License
22.56k stars 2.64k forks source link

getVisibleSelectionRect returning null while typing with decorator #262

Open nathanborror opened 8 years ago

nathanborror commented 8 years ago

I'm trying to make a simple typeahead. This might be incorrect but my current approach uses a selectionContainsEntity function which accepts a decorator strategy and the current editor state. If the cursor is in or alongside a match then I toggle a state property to render the typeahead component which uses getVisibleSelectionRect(window) to determine where to position the typeahead suggestions.

Mostly works but getVisibleSelectionRect seems to return null when the cursor is in the act of typing, moving the cursor into the entity returns the correct position but as soon as typing begins it goes back to null. Here's an example:

gif

If I remove the decorator and just always show the typeahead then getVisibleSelectionRect returns what you'd expect on each keystroke. Here's a simplified version of the code I'm working with:

import React, {Component} from 'react'
import {render} from 'react-dom'
import {Editor, EditorState, CompositeDecorator, getVisibleSelectionRect} from 'draft-js'

class Base extends Component {

  state = {editorState: this.reset(), showTypeahead: false};

  reset() {
    const editorState = EditorState.createEmpty()
    const decorator = new CompositeDecorator([
      {strategy: mentionStrategy, component: Mention}])
    return EditorState.set(editorState, {decorator})
  }

  change(editorState) {
    this.setState({
      editorState: editorState,
      showTypeahead: selectionContainsEntity(mentionStrategy, editorState)})
  }

  render () {
    const {editorState, showTypeahead} = this.state
    return (
      <div>
        <Editor ref="text" editorState={editorState}
            onChange={this.change.bind(this)} />
        {showTypeahead &&
          <Typeahead editorState={editorState} />}
      </div>
    )
  }
}

// Typeahead

class Typeahead extends Component {

  render() {
    const targetRect = getVisibleSelectionRect(window)
    const styles = {
      top: targetRect ? targetRect.top + 32 : 0,
      left: targetRect ? targetRect.left : 0}
    return <div className='typeahead' style={styles}></div>
  }
}

// Mentions

export function mentionStrategy(contentBlock, callback) {
  const re = /\@[\w]+/g
  const text = contentBlock.getText()

  let matchArr, start
  while ((matchArr = re.exec(text)) !== null) {
    start = matchArr.index
    callback(start, start + matchArr[0].length)
  }
}

export const Mention = props => {
  return <span {...props} className='mention' spellCheck={false}>{props.children}</span>
}

// Utils

export function getSelectedBlocks(contentState, anchorKey, focusKey) {
  const isSameBlock     = anchorKey === focusKey
  const startingBlock   = contentState.getBlockForKey(anchorKey)
  const selectedBlocks  = [startingBlock]

  if (!isSameBlock) {
    let blockKey = anchorKey
    while (blockKey !== focusKey) {
      const nextBlock = contentState.getBlockAfter(blockKey)
      selectedBlocks.push(nextBlock)
      blockKey = nextBlock.getKey()
    }
  }
  return selectedBlocks
}

export function selectionContainsEntity(strategy, editorState) {
  const contentState      = editorState.getCurrentContent()
  const currentSelection  = editorState.getSelection()
  const startKey          = currentSelection.getStartKey()
  const endKey            = currentSelection.getEndKey()
  const startOffset       = currentSelection.getStartOffset()
  const endOffset         = currentSelection.getEndOffset()
  const isSameBlock       = startKey === endKey
  const selectedBlocks    = getSelectedBlocks(contentState, startKey, endKey)

  let entityFound = false

  selectedBlocks.forEach(block => {
    strategy(block, (start, end) => {
      if (entityFound) return
      const blockKey = block.getKey()

      if (isSameBlock && (end < startOffset || start > endOffset)) return
      else if (blockKey === startKey && end < startOffset) return
      else if (blockKey === endKey && start > endOffset) return
      entityFound = true
    })
  })
  return entityFound
}

// Render

render(<Base />, document.getElementById('root'))
hellendag commented 8 years ago

Hi @nathanborror!

Can you verify whether https://github.com/facebook/draft-js/blob/master/src/component/selection/getVisibleSelectionRect.js#L29 properly returns a range value in the null case?

nathanborror commented 8 years ago

Hey @hellendag :)

It's returning Range {startContainer: <span>, startOffset: 0, endContainer: <span>, endOffset: 0, collapsed: true, …}

jjjjw commented 8 years ago

I'm seeing something similar. For me, getVisibleSelectionRect always returns null when the cursor is at the beginning of a line.

The value for the range is {collapsed:true, commonAncestorContainer: span, endContainer: span, endOffset:0, startContainer: span, startOffset: 0}

This is in Chrome.

carlesba commented 8 years ago

I think the problem is because getVisibleSelectionRect is executed on render method so DOM is not ready yet. I've solved this by reading getVisibleSelectionRect on componentDidUpdate (so we know the DOM has been updated with the proper position) and then forcing a re-render.

An simple implementation of this could be:

const Foo = React.createClass({
  getInitialState () {
    return {
      menuPosition: null
    },
    componentDidUpdate () {
       const menuPosition = getVisibleSelectionRect(window)
       // the tricky part is to play with this if statement to do only the proper re-renders
       if(this.state.menuPosition === null || this.state.menuPosition !== menuPosition) {
          this.setState({menuPosition})
       }
    },
    render () {
      return (
        <div>
          <Editor {...whatever} />
          <Menu position={this.state.menuPosition} />
        </div>
      )
    }
})
tleunen commented 8 years ago

The issue also occurs when we try to calculate getVisibleSelectionRect(window) when no character is inside the editor (on the first onChange call). I wonder what would be the best way of getting the selection rect because of that, I wanted to use it to know where to open a suggestions box.

aanchalgera commented 6 years ago

@jjjjw Were you able to solve this?