How to imperatively set value of @lexical/react plaintext editor, while retaining `Selection`?
Asked Answered
M

1

10

What I need to get done

I want to make a simple controlled lexical plaintex editor - one which is controlled by a parent string field.

But I'm really struggling with getting my editor to simultaneously:

  1. Be adopting parent state whenever it changes
  2. Retain Selection after adopting parent state
  3. Not automatically focus just because the external value changed (and got adopted)

Where I got so far

I tried a couple things, but this is the closest I got - Sandbox here:

export const useAdoptPlaintextValue = (value: string) => {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    editor.update(() => {
      const initialSelection = $getSelection()?.clone() ?? null;
      $getRoot().clear();
      $getRoot().select(); // for some reason this is not even necessary
      $getSelection()?.insertText(value);
      $setSelection(initialSelection);
    });
  }, [value, editor]);
};

This approach works well when writing into the input itself, but the imperative adoption of value only works until the input was first selected. After it has already been selected (even when un-selected again), editor only adopts the value for one "render frame" and then immediately re-renders with the old value. I'm clearly doing something wrong with selection, because:

  1. removing setSelection(initialSelection) also removes this problem - but then selection doesn't get maintained between updates, which is also unacceptable.
  2. I'm getting this error on every keystroke:

updateEditor: selection has been lost because the previously selected nodes have been removed and selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.

... It seems to me that initialSelection retains a reference to nodes that are deleted by $getRoot().clear(), but I tried working my way around it and got nowhere.

I'll be glad for any advice/help about my code or towards my goal.

Thank you 🙏

Moulmein answered 10/5, 2022 at 18:15 Comment(0)
H
9

Bit of background information on how Lexical works (feel free to skip to the next point)

Lexical utilizes EditorState as the source of truth for editor content changes and selection. When you do an editor.update, Lexical creates a brand new EditorState (a clone of the previous) and modifies it accordingly.

At a later point in time (synchronously or asynchronously), these changes are reflected to the DOM (unless they come from the DOM directly; then we update the EditorState immediately).

Lexical automatically recomputes selection when the DOM changes or nodes are manipulated. That's for a very good reason, selection is hard is to get right:

  • Selected node is removed but has siblings -> move to sibling
  • Selected node is removed but has no siblings -> find nearest parent
  • Text node content changes -> understand whether the current selection fits
  • DOM selection changes because of composition or beforeinput -> replicate selection
  • etc.

This selection recomputation is also initially done to the EditorState (unless it comes from the DOM directly) and later backed to the DOM.

Focus restoration

By default selection reconciliation will restore DOM selection to make sure it matches the source of truth: the EditorState. So wherever you move the selection (even if it's part of the automatic selection restore described above) will move the focus to the contenteditable.

There are 3 exceptions to this rule:

  1. $setSelection(null); -> clears selection
  2. readonly editor.setReadOnly -> you are not supposed to interact with a readonly editor
  3. Collaboration editor.update(() => ..., {tag: 'collaboration'}) -> we created an exception for this

I would never recommend 1. for this purpose since the editor will lose track of the position.

The second makes sense when the editor is truly readonly.

The third can work for you as a temporary patch but ultimately you want a better solution than this.

Another temporary patch for your use case would be to store document.selection and restore it as soon as the Lexical contenteditable takes control.

That said, it seems like a reasonable use case to be able to skip DOM selection reconciliation at times programatically. I have created this proposal (https://github.com/facebook/lexical/pull/2134).

Side note on your error

Your selection is likely on the paragraph or some text node. When you clear the root, you destroy the element. In most cases, we attempt to restore the selection as listed above but selection restoration is limited to valid selections. In your case you are moving the selection to an unattached node (already removed as part of root.clear()).

Herndon answered 11/5, 2022 at 8:11 Comment(3)
Thank you very much for your input! I slept on it and realized I was aiming at the wrong target. I thought I needed to restore selection, because otherwise my caret kept jumping to end when typing anywhere in the input. But I now instead prevent updates when the parent state already matches the current state ($getRoot().getTextContent() === value) - this way reconcilliation never gets triggered and the input works just as I want it!Moulmein
This is a helpful explanation, but none of the workarounds seem to work (anymore?).Divider
I found this answer when I've been looking for a solution for not focusing on the editor when updated the state. After spending so much time on documents found nothing. So thank you!!! I think the 3rd option as a workaround must be an option like skipFocus?Derzon

© 2022 - 2024 — McMap. All rights reserved.