facebookarchive / draft-js

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

Cursor positioning problem using Entity #627

Open schuchowsky opened 8 years ago

schuchowsky commented 8 years ago

Hello, it's my first post here, so sorry in advance for any mistake.

I'm using Draft to create a message input component.

I'm using decorators to decorate "hashtags", and Entity to render emojis.

I'm using entities for emojis in order to set the text "IMMUTABLE", and be able to delete the whole text from the emoji at once, which is a pattern like this :emoji:

The problem is with the input focus when rendering the emojis. When the cursor is "in the front" of the emoji, it starts typing "behind" the emoji.

I've found that setting data-offset-key property into my emoji component improved a lot the control of the cursor,

DOM using data-offset-key: http://hnordt.d.pr/1ghat+

Problem with the cursor: http://hnordt.d.pr/1bk5d+

As you can see, when the cursor is in the next character after the emoji (Entity), it writes before the emoji. Only when navigating to the next character, it begins typing in the correct span.

Code when adding an emoji (emoji.colons is the emoji text ie ":emoji:"):

 handleSelectEmoji(emoji) {
    const { editorState } = this.state
    const currentContent = editorState.getCurrentContent()
    const selection = editorState.getSelection()

    // Adding emoji and applying entity to it
    const entityKey = Entity.create('EMOJI', 'IMMUTABLE', {emoji: emoji.id})
    let newContent = Modifier.insertText(currentContent, selection, emoji.colons)
    let newState = EditorState.createWithContent(newContent, compositeDecorator)
    let selectionState = SelectionState.createEmpty(newContent.getFirstBlock().getKey())
    let focusOffset = selection.getFocusOffset() + emoji.colons.length
    selectionState = selectionState.merge({
      anchorOffset: selection.getAnchorOffset(),
      focusKey: newContent.getFirstBlock().getKey(),
      focusOffset: focusOffset,
    })
    newContent = Modifier.applyEntity(newContent, selectionState, entityKey)
    newState = EditorState.createWithContent(newContent, compositeDecorator)

    //Setting cursor position after inserting emoji to content
    selectionState = selectionState.merge({
      anchorOffset: focusOffset,
      focusKey: newContent.getFirstBlock().getKey(),
      focusOffset: focusOffset,
    })
    newState = EditorState.forceSelection(newState, selectionState)

    this.setState({ editorState: newState })
    setTimeout(() => {
      this.focus()
    },0)
  }

Decorator to transform emoji text in image:

const EmojiSpan = (props) => {
  return (
    <span data-offset-key={props.offsetKey} className="emoji-input-message">
      <Emoji
        sheetURL="img/sheet_apple_64.png"
        emoji={{id:props.decoratedText.substring(1, props.decoratedText.length -1), skin: 1}}
        size={22}
        skin={1}
      />
    </span>
  )
}

function findEmojis(contentBlock, callback) {
  contentBlock.findEntityRanges(
    (character) => {
      const entityKey = character.getEntity();
      return (
        entityKey !== null &&
        Entity.get(entityKey).getType() === 'EMOJI'
      );
    },
    callback
  );
}

const compositeDecorator = new CompositeDecorator([
  {
    strategy: flowStrategy,
    component: FlowSpan,
  },
  {
    strategy: findEmojis,
    component: EmojiSpan
  }
])

Any ideas to solve that? Many thanks!

stopachka commented 8 years ago

If I remember correctly, this is a known issue with how the browser handles the selection.

cc @spicyj @hellendag -- Is there some ideas we have on solving this, based on the way it's done at FB?

schuchowsky commented 8 years ago

Thanks @stopachka. I've extracted the code related to this issue from my project and created a public repository with it. Repository Demo Hope it helps!

sophiebits commented 8 years ago

@stopachka This sounds like the inline-block problem we discussed this week. I don't think we know of any good solution currently. On Facebook we implement emojis by rendering a wide space and adding the emoji image as a background image.

schuchowsky commented 8 years ago

The lib that I'm using for emojis is emoji-mart. It renders a span with background-image for emojis. This causes the cursor to "disappear", once the cursor goes inside the emoji span. I'll create a PR for them, because I've already changed it to render a img instead of a span. As an img element, it solves the problem with the cursor disappearing, but the main problem persists.

Feel free to clone my repo and try it, and hopefully I can help you too. Thanks in advance.

flarnie commented 7 years ago

Thanks for setting up that repository as an example - we would be open to PRs related to this. It sounds like other folks are running into this issue.

nkemcels commented 4 years ago

While waiting for a permanent fix for this issue, Here is what I implemented

let contentState = this.state.editorState.getCurrentContent();
let currentSelection = this.state.editorState.getSelection();

contentState = contentState.createEntity("EMOJI", "IMMUTABLE", emo.colons + "\u200A");
let emoEntityKey = contentState.getLastCreatedEntityKey();

let newContentState = Modifier.insertText(contentState, currentSelection, emo.colons + "\u200A", null, emoEntityKey);
let newEditorState = EditorState.push(this.state.editorState, newContentState, "insert-characters");

newEditorState = EditorState.push(this.state.editorState, newContentState, "insert-characters");
newEditorState = EditorState.moveFocusToEnd(newEditorState);
newEditorState = EditorState.forceSelection(newEditorState, newContentState.getSelectionAfter())

this.setState(editorState: newEditorState);

And then, the decorator strategy function:

function emojiMartRenderStrategy(contentBlock, callback, contentState) {
    contentBlock.findEntityRanges(
        character => {
            const entityKey = character.getEntity();
            return entityKey !== null && contentState.getEntity(entityKey).getType() === "EMOJI";
        },
        (start, end) => callback(start, end - 1)
    );
}

notice that I'm calling the callback here with end - 1. The reason for this is because I'm attaching a thin space character (\u+200A) to the entity data when creating the emoji entity. This will make it possible for the cursor to be able to move in front of and behind the emoji. For those who mind, here is how I render the emoji entity (using emoji-mart)

const EmojiMartIcon = props => {
    let emoji = Emoji({
        html: true,
        set: "apple",
        emoji: props.decoratedText,
        size: props.emojiSize || 22
    });
    return emoji ? (
        <span
            style={{ width: 24, display: "inline-block" }}
            dangerouslySetInnerHTML={{
                __html: emoji
            }}
        />
    ) : (
        <>{props.decoratedText || props.emojiColons}</>
    );
};