ianstormtaylor / slate

A completely customizable framework for building rich text editors. (Currently in beta.)
http://slatejs.org
MIT License
29.59k stars 3.23k forks source link

make slate a controlled input #5281

Open macrozone opened 1 year ago

macrozone commented 1 year ago

Problem

Slate was recently changed to be an uncontrolled component. this leads to massive problems for integrators of slate as uncontrolled components are not predictable because they have an inner state that is not fully controllable from outside.

Uncontrolled components therefore lead to bugs and inconsistencies

Solution

Slate should be a controlled component, means that it will always show consistently what is passed as a value

Alternatives

a workaround is shown here https://github.com/ianstormtaylor/slate/issues/4992 but that also does not always work. Since it relies on a useEffect the execution of the effect might be delayed and things get out of sync.

Context I am maintaining https://github.com/react-page/react-page and we use slate for rich-text editing. Since the mentioned update we have a hard time to fix all inconsistencies regarding slate

aboveyunhai commented 1 year ago

Uncontrolled component itself its fine, the problem is that the person with knowledge who made those breaking changes, failed to include the uncontrolled doc for those changes, and neglect the controlled behavior in React. It starts to turn out to be a mess now.

One thing I am pretty sure is that if you are using useEffect to update the state without waiting something externally from outside of the app (like waiting for an api call and then update the internal data). You might be doing something wrong most of the times.

I believe the "intended" way based on the PR is to directly call those Transforms.XXX, Editor.apply, etc. to do the state(editor value) manipulation. You have to simplify, wrap those inner functions and expose to the users if you want to let user control the value since slate-react currently failed to provide some shortcut helper functions for this part.

erano067 commented 6 months ago

Any update?

12joan commented 1 month ago

I think keeping Slate as an uncontrolled input is the right choice, for reasons I'll go into below. But to summarise, the state requirements of a text editor are far too complex for a purely controlled approach to be useful. At the bottom of this comment, I present a proof of concept to navigate this complexity and make Slate partially (and usefully) controlled in the rare cases in which this is truly the most natural solution.

What state does Slate use? The most obvious state Slate uses is editor.children, responsible for the content or "value" you see in your editor. If this were the only way in which Slate is stateful, then making Slate controlled would be simple; you would just need to expose a pair of props to pass in and set the value.

Unfortunately, text editors store more state than just their value. The most problematic examples for making Slate controlled are editor.selection and editor.history.

editor.selection stores the current selection in the form of a range. It must either be null (in which case no part of the editor is selected), or it must reference a pair of valid points in the editor. A point, as the docs state, is a specific text node and an offset within that text node. Slate will crash if editor.selection references a point that does not exist. When making changes using operations, the selection is updated automatically.

editor.history (if we're using the history plugin) stores two stacks of operations: an undo stack and a redo stack. An operation, for those who aren't familiar with the inner workings of Slate, describes an atomic, reversible change to the value of the editor. When the user makes any change to the content of the editor, such as by typing, these changes are applied (via the editor.apply method) using operations. Each operation is also copied onto the undo stack. When the user performs an undo operation, a batch of operations is popped off the undo stack, inverted to produce the opposite effect, applied to the editor, and then pushed onto the redo stack. For this process to work without error, all changes to the editor's value must happen through operations.

Why do these present an obstacle to making Slate controlled? The most obvious way of making Slate controlled is to directly replace editor.children with a new value and re-render the Slate component. Unfortunately, this breaks the rules that selection and history depend on.

Firstly, selection. Suppose the user's cursor is inside a particular text node, and we replace editor.children with a new value in which that text node does not exist, or where that text node now has a length less than the offset of the user's selection in the old text node. In either case, editor.selection is now pointing to a non-existent point. Since Slate cannot resolve this point to a DOM selection, Slate must crash.

When modifying the editor content using operations, these issue do not occur. This is because Slate intelligently modifies the selection in response to operations. For example, suppose the user's cursor, denoted by the symbol '|', is inside a text node like this: "Hello w|orld". The offset of the selected point is 7 (the length of "Hello w"). Now suppose we use an operation to delete the word "Hello" and the following space from this text node. The resulting text "world" has length 5, so our old offset of 7 no longer exists. Slate corrects for this by subtracting from the offset the length of the text that was removed, 6. Thus, our new offset is 1: "w|orld". To the user, it appears as if the selection has not moved, as it is still between the 'w' and the 'o' in "world".

Note that the above correction is only possible because Slate knows the exact nature of the change that occured to the editor's value. This is why operations must be used when modifying the editor's value to ensure that the selection remains correct.

Secondly, history. Suppose we start with a text node "Hello world" again (the selection is not important), and the user adds an '!' to the end to make "Hello world!" This insertion is made by way of an operation that looks something like this: { type: 'insert_text', path: [0, 0], offset: 11, text: "!" }. This operation is pushed onto the undo stack so that the user can undo it if they so choose. Now suppose we directly modify editor.children to replace the text node with "Hello, world!", note the addition of a comma.

If the user now performs an undo, the insert_text operation from earlier will be inverted to produce { type: 'remove_text', path: [0, 0], offset: 11, text: "!" }, and this operation will be applied to the editor. The default implementation of remove_text doesn't care about the exact contents of its text property, only text.length. Thus, it does not matter that due to the addition of the comma earlier, the character at offset 11 is now 'd' instead of '!'; Slate will happily remove it as per the operation. The confused user will now be left with "Hello, worl!", note the lack of a 'd'.

Okay, but I still want to make external changes to my Slate editor One solution is to erase the problematic editor.selection and editor.history when the value of editor.children is modified. For many use cases, this may be appropriate, but it comes with two unfortunate side effects: the user's selection will be lost, and so will their undo history.

What we really want is to use operations to transform the existing editor.children to our new desired value, but avoid having to think in terms of operations when it's more natural to treat Slate as a controlled component whose value we can just change.

A proof of concept solution Luckily, @pubuzhixing8 found a way of inferring the required operations to transform one Slate value into another in their slate-diff library. I came across this library when working on a rich-text diffing solution for Plate, and I can attest that for simple changes, the algorithm can be made to be very reliable with a few modifications.

Using slate-diff, we can take the existing editor.children and our desired value and compute a set of operations that will get us from one to the other while keeping selection and history intact. I implemented a proof of concept for this here: https://github.com/ianstormtaylor/slate/compare/6548197...12joan:slate:proof-of-concept/controlled-slate. There are a few edge cases and performance issues, but those could be ironed out with sufficient effort.

https://github.com/user-attachments/assets/cea650ca-cae4-4d87-8f75-557fbacd0fbc