slate: Selection is null after editor loses focus

Do you want to request a feature or report a bug?

Bug

What’s the current behavior?

Current behavior is that when you click into the toolbar or out of the document the editor’s selection becomes null.

2020-01-08 13 25 51

https://codesandbox.io/s/fervent-bouman-ju71u?fontsize=14&hidenavigation=1&theme=dark

Tested on Firefox, Safari and Chrome although Firefox has different behavior. Firefox sometimes sets the cursor position to the end of the text in the editor depending on how you focus out of the editor.

Slate: 0.57.1 Browser: Chrome / Safari / Firefox OS: Mac

What’s the expected behavior?

Focus out shouldn’t erase editor selection.

Changes to Example Site To Produce Behavior

In order to test this we forked the rich text example and made sure the toolbar style buttons did not disable on focus out. Then we used the ReactEditor.focus method in the MarkdownButton component’s onMouseDown handler in the richtext.js file.

About this issue

  • Original URL
  • State: open
  • Created 4 years ago
  • Reactions: 56
  • Comments: 37 (4 by maintainers)

Commits related to this issue

Most upvoted comments

Here is my solution to this problem:

  1. Put command buttons (like “link” on the gif in the first post) out of the <Slate> tag. This is required, so onBlur event will be fired before your button is clicked.
  2. Add “onBlur” handler to the <Editable> tag. In that handler save selection to some property on the editor. E.g. blurSelection.
  3. Slate sets selection to null after blur, so before executing a command on the editor, you need to set selection to the saved one. Do it with Transforms.select(editor, editor.blurSelection); (blurSelection is the name of a variable from step2.
  4. Run your regular command as if a selection was there. Everything will work exactly the same.
  5. Bonus: now, since we have selection we can use ReactEditor.focus(editor) to return focus, so users can just continue typing.

Works well for all the basic commands my editor has: lists, numbers, formatting, headers, etc.

ezgif com-video-to-gif

Similar thing happened when I tried to use dialog box in link example instead of alert. While image upload as well the editor focus loses and the image gets appended at the last node instead of at the cursor location. Is there a way to control or change focus?

Hey guys, what if you use onMouseDown instead of onClick? This doesn’t reset focus for me.

Found a work around for this issue thanks to a kind developer on slack channel. Writing here in case anybody needs it. Store the selection value just before the editor loses focus. In my case it was when I clicked on input field, so I stored it just when dialog box opens. Similarly it can be applied to image upload, iframes or any action where editor loses focus.

const editorSelection = useRef(editor.selection);
useEffect(() => {
    if (openDialog) {
        editorSelection.current = editor.selection;
    }
}, [openDialog]);

You can get around this issue by setting readOnly to true before opening the link input or focusing outside the editor, and then setting back to false when done.

However, this PR will need to be merged, since readOnly is broken right now.

https://github.com/ianstormtaylor/slate/pull/3388/files

Keeping on this, is there a way to actually keep it selected, as in highligting the text when losing focus ?

I don’t know if this is a bug or expected behaviour. But here if you have value of selection with you (which is editorSelection.current ) you can pass it down to editor ( editor.selection = editorSelection.current ) before passing editor to Transforms.insertNode or anywhere else. If you want to show the selection, may be try Transforms.setSelection or Transforms.select.

Yeah, I am currently trying to do Transforms.setSelection with the value I got from the onBlur event , cant seem to make it work. Setting the editor.selection does seem to work correctly.

I just need to find a way to highlight this and generalize the onBlur Event.

I have found that this is checking the onSelectChange event from the dom to unselect , so my next thing is going to try to disable the unselection there , I will report back on my findings.

So a quick work around, is to not allow the editor to be unselected - You can either run your own logic, or you can just “monkey patch” Transforms.deselect to be a empty function in the begginging of your app, this worked like a charm, and I can seem where this is actually being used on the internals of Slate apart from Focus/UnFocus. So so far this is my go to solution. Better scenario would be to actually change Editable to not call deselect via a prop or something, and you woulc manually call deselect ( in case for multiple editors on the same page)

Keeping on this, is there a way to actually keep it selected, as in highligting the text when losing focus ?

I don’t know if this is a bug or expected behaviour. But here if you have value of selection with you (which is editorSelection.current ) you can pass it down to editor ( editor.selection = editorSelection.current ) before passing editor to Transforms.insertNode or anywhere else. If you want to show the selection, may be try Transforms.setSelection or Transforms.select.

I needed to sort this out to make a Slate editor look and act like a textarea, which has a border div that can be clicked to focus the editor and needs to remember the selection. It’s basically the same approach that @bearz describes. Also important is the // @refresh reset comment, which prevents crashing on React Fast Refresh in newer versions of React.

import clsx from 'clsx';
import React from 'react';
import { createEditor, Editor, Node, Transforms } from 'slate';
import { withHistory } from 'slate-history';
import { Editable, ReactEditor, Slate, withReact } from 'slate-react';
import './EditorTextArea.scss';

export interface EditorTextAreaProps {
  value: Node[];
  onChange: (value: Node[]) => void;
}

// @refresh reset
const EditorTextArea: React.FC<EditorTextAreaProps> = ({ value, onChange }) => {
  const editor = React.useMemo(
    () => withHistory(withReact(createEditor())),
    []
  );

  const [focused, setFocused] = React.useState(false);
  const savedSelection = React.useRef(editor.selection);
  const onFocus = React.useCallback(() => {
    setFocused(true);
    if (!editor.selection) {
      Transforms.select(
        editor,
        savedSelection.current ?? Editor.end(editor, [])
      );
    }
  }, [editor]);
  const onBlur = React.useCallback(() => {
    setFocused(false);
    savedSelection.current = editor.selection;
  }, [editor]);

  const divRef = React.useRef<HTMLDivElement>(null);
  const focusEditor = React.useCallback(
    (e: React.MouseEvent) => {
      if (e.target === divRef.current) {
        ReactEditor.focus(editor);
        e.preventDefault();
      }
    },
    [editor]
  );

  return (
    <div
      ref={divRef}
      className={clsx('editor-textarea', { focused })}
      onMouseDown={focusEditor}>
      <Slate editor={editor} value={value} onChange={onChange}>
        <Editable onFocus={onFocus} onBlur={onBlur} />
      </Slate>
    </div>
  );
};

export default EditorTextArea;

@AleksandrHovhannisyan hey, I put some time into researching and going through other people’s code and came up with this prototype:


type EditorWithPrevSelection<V extends Value = Value> = PlateEditor<V> & {
  prevSelection: BaseRange | undefined;
};

export const createFakeSelectionPlugin = createPluginFactory({
  key: FAKE_SELECTION_MARK,
  isLeaf: true,
  handlers: {
    onBlur: (editor: EditorWithPrevSelection<Value>) => (event) => {
      event.preventDefault();
      const currentSelection = editor.selection;
      const hasSelection = !!currentSelection && isSelectionExpanded(editor);

      if (hasSelection) {
        setMarks(editor, { [FAKE_SELECTION_MARK]: true });
        editor.prevSelection = editor.selection;
      }
    },
    onFocus: (editor: EditorWithPrevSelection<Value>) => (event) => {
      event.preventDefault();
      const { prevSelection } = editor;
      if (prevSelection) {
        Transforms.select(editor as any, prevSelection);
        editor.prevSelection = undefined;

        removeMark(editor, {
          key: FAKE_SELECTION_MARK,
          shouldChange: false,
          mode: 'all',
        });
      }
    },
  },
  component: withProps(StyledLeaf, {
    as: FAKE_SELECTION_MARK,
    styles: {
      root: {
        background: 'var(--chakra-colors-textSelect)',
      },
    },
  }),
});

hope this helps anyone that comes across this issue!

edit: i’m using plate for my plugin system

One alternative solution: When focus leaves the editor, wrap the current selection in a fake custom selection node. Then, instead of relying on editor.selection being non-null, perform all operations and checks relative to that unique selection node. And instead of using editor.selection, you can now define a custom editor.getSelection method that returns either the path/location of that fake selection or falls back to editor.selection. Then remove the fake selection when the editor is re-focused.

We did this in our code base and it works well; it allows you to implement a floating toolbar and have inline inputs next to your editor. I’m hoping to write an article/tutorial soon to clarify how all of this works and what the code looks like. But that’s the basic idea: Just like you have other inline nodes (hyperlinks and whatever else), you can also mock up a selection node that doesn’t get serialized/deserialized and is only a run-time helper.

The solution of @bearz works, but it still removes the selection visually, which is problematic for some use cases.

I want to change the font size for the selection via typing the font size into an input field, and for the user it is disturbing that the selection is no longer there visually, only “under the hood”.

Keeping on this, is there a way to actually keep it selected, as in highligting the text when losing focus ?

I was able to keep the selected text highlighted when clicking on a toolbar button by using the following prop on Editable:

onBlur={(e) => e.preventDefault()}

That doesn’t stop the editor from getting set to null when clicking in the toolbar div but outside of a button. Very confusing, have no idea why that happens.

Hey guys, what if you use onMouseDown instead of onClick? This doesn’t reset focus for me.

I also added a preventDefault() at the end of the onMouseDown and it kept both selection and focus for me.

Like others in this thread, I was also caught by the unexpected behaviour of setSelect. I’ve created https://github.com/ianstormtaylor/slate/pull/4247 explaining the difference

basically,if you need to regain focus with the previous selection, use select instead of setSelect

@raitucarp I don’t have a project set up to try it out, but most likely keeping the ref and passing it to a ButtonComponent as a prop should do a trick. Example code (may contain errors as I haven’t written JSX in forever):

let  editorRef = React.useRef();
 return 
(<div>
     <ButtonComponent editor={editorRef} />
    <Slate>
        <Editable ref={editorRef}>
          ....
        </Editable>
    </Slate>)

Consider setting CSS for toolbars buttons outside the slate editing area: user-select: none; <button onClick={handleClick} style={{userSelect:'none'}}>test</button> Reference MDN: https://developer.mozilla.org/zh-CN/docs/Web/CSS/user-select After setting, the selection will not be lost

You can make it work even when splitting nodes, here is a basic example that works for me:

saveSelection = () => {
  this.editor.savedSelection = this.editor.selection;
};

render () {
  return (
    <Slate editor={this.editor} value={this.state.value} onChange={this.handleChange}>
      <FontColorPicker editor={this.editor} defaultValue='rgba(0, 0, 0, 1)'/>
      <Editable renderLeaf={this.renderLeaf} onBlur={this.saveSelection}/>
    </Slate>
  );
}

In FontColorPicker:

handleChange = (color) => {
  if (this.props.editor.savedSelection) {
    Transforms.select(this.props.editor, this.props.editor.savedSelection);
  }

  Editor.addMark(this.props.editor, 'color', color);

  const sel = this.props.editor.selection;

  Transforms.deselect(this.props.editor);

  setTimeout(() => {
    Transforms.select(this.props.editor, sel);
    ReactEditor.focus(this.props.editor);
  }, 10);
};

For some reason just running ReactEditor.focus(this.props.editor); after Editor.addMark was putting the cursor back to the start even with a timeout, but deselect + select works.

Step 5 returns focus for me. I can continue to type exactly from the same place. Make sure that your command doesn’t modify selection under the hood. If you split nodes or change blocks that might be a case.

@davevilela Sorry for the tease, I know that’s not a ton of info to go off of. I’m still hoping I can get around to writing a blog post about it one of these days.

I know Notion does something very similar in its editor; when you highlight some text and try to insert a hyperlink, they insert a fake blue wrapper span styled to look like a regular selection, and then they delete it afterwards.

still removes the selection visually

But that was also the case in the previous version? It’s not easy to keep the selection visually because it’s the same document. You could have a custom “Selection” plugin which draws a background behind the selection to have a visual effect maybe?