simplify the "Change vs. Editor" question
See original GitHub issueDo you want to request a feature or report a bug?
Idea.
What’s the current behavior?
This is something I was discussing with @alanctkc in Slack. Right now, there are two “main” instances that you care about when developing plugins: the Editor
and the Change
.
This issue is that they are used semi-interchangeably, but they’re different. For example commands receive a change
object. Whereas queries receive an editor
object. And event handlers receive a change
object. But components should receive an editor
.
That alone isn’t a huge deal, but it does complicate things.
But things get worse when you consider that there is confusion between editor.value
and change.value
—people might not even realize they are different. But editor.value
is the currently rendered value of the editor, and it only changes after new props are passed in. Whereas change.value
is the most-up-to-date value object that has the newest operations in the change applied to it.
This distinction is a bit complex. It means that you can’t just access editor.value
in your queries and have everything work as expected.
What’s the expected behavior?
A fairly simple solution that @alanctkc came up with is to change queries to actually be passed a change
object, which will default to the currently active change. This seems strange, but it’s actually kind of ingenious because it means that queries can always access change.value
as the most-up-to-date.
This could work.
I was wondering though, if there might be a slightly different solution that we could use, by getting rid of the current Change
object altogether. Sounds drastic, but it might actually result in a simpler mental model.
It would entail…
- Registering commands and queries directly on editor. Right now if you want to invoke a command on the editor you do
editor.command('insertText', 'word')
. Whereas if you want to invoke it on a change you dochange.insertText('word')
. Under the new architecture, the commands and the queries would be available as top-level methods on the editor itself:
editor.insertText('word').splitBlock()
if (editor.isQuoteAllowed()) {
editor.setQuoteBlock()
}
-
Tweak changes would be emitted asynchronously. To make this possible, we’d tweak the current logic to emit changes via
onChange
in an asynchronous way, more similarly to howsetState
works. (The current synchronous architecture might already be causing problems.) Any command would queue a change (just like React state does) and then the queue is flushed on the next tick. That way you can continue chaining commands synchronously just like you can now. -
Change
editor.value
andeditor.operations
to update synchronously. As you invoke commands, theeditor.operations
will now hold all of the operations that have been created, andeditor.value
will now update to reflect those operations being applied. This means that you can always grabeditor.value
and know that it is the most up-to-date state, without worrying about any sort of race conditions. Although changes are emitted asynchronously, theeditor.value
stays up to date, so testing remains straightforward:
it('my test', () => {
...
fn(editor)
assert.deepEqual(editor.value.toJSON(), expected.toJSON())
})
-
Remove the need for
editor.change
. With the commands registered directly on the editor, and the changes emitted asynchronously, you no longer need to doeditor.change(change => change.splitBlock())
, because you can calleditor.splitBlock()
directly. This would reduce a decent amount of verbosity in the codebase. -
Remove the current
Change
object. With all of that, you no longer need theChange
object as we know it today, since all of its behaviors are present on the editor. Instead, we’d introduce a super-super-thinChange
model which holdsvalue/operations
and is emitted toonChange
. This is the only place we still benefit from having a separate “change” that can be referred to on its own. -
Pass
editor
to commands and handlers. With thechange
objects eliminated, all of the middleware can now receive theeditor
directly, so there’s no confusion. And commands, queries, and event handlers become even simpler to reason about:
onKeyDown(event, editor, next) {
if (event.key === 'Enter' && event.shiftKey && editor.isParagraphActive()) {
event.preventDefault()
editor.insertText('\n')
}
}
- Allow
editor.command/query
to take functions. One thing that is useful on the currentchange
object is thechange.call
method. This functionality can easily be added to theeditor.command
andeditor.query
methods instead, which actually makes plugin development even easier because users can supply commands as name strings or as custom functions:
Linkify({
when: 'isLinkAllowed',
command: 'setLink',
})
Linkify({
when: editor => ...,
command: (editor, url) => ...,
})
- Remove the
editor.event
method. This is low-level, but eliminating the need forChange
objects allows us to remove one more method on the editor, because theeditor.event
method was only needed to create an interim change object. Without them, we can useeditor.run
directly in tests:
editor.run('onKeyDown', { key: 'Tab', ... })
- Potentially introduce a “flags” concept. Right now there are some native flags that are special-cased:
withoutNormalizing
,withoutSaving
, etc. These can be implemented in userland, but it’s sort of hacky. We could choose to add a “flags” concept, which could allow them to be “registered” just like commands and queries are:
onConstruct(editor, next) {
editor.registerFlag('saving')
next()
}
Issue Analytics
- State:
- Created 5 years ago
- Reactions:4
- Comments:16 (11 by maintainers)
Top GitHub Comments
@ericedem that’s great to hear! I didn’t realize that actually 😄
@alanctkc yup, that is one of my main reservations with this right now. I’ve tried to eliminate more of the editor’s top-level methods to make room for the commands/queries and keep the collisions to a minimum, but it’s still a bit scary.
Currently we have:
So it’s pretty minimal. But already the
setValue
collides with thesetValue
command. So we’ll need to figure out a new name for that command, or a new way to set the value, if you have ideas.(Side note: I was already thinking about breaking “decorations” out into
addDecoration
andremoveDecoration
commands for other UX reasons to make them easier to use, so this isn’t that bad of a thing, since it just leavesvalue.data
. Which we can callsetData
I guess.)Right now on changes we have top-level dynamic methods. So if we’re removing the change objects, keeping the dynamic methods is the easiest migration path. For that reason, although I’m not 100% convinced it’s the right decision, I think we should keep them. (I’m like 75/25 right now.) We can always remove them later and it will be the same amount of work for people, but this way we defer it.
@alanctkc you’re right, the only distinction is the return value. Commands always return the
editor
for chaining, whereas queries returnAny
.@alanctkc I agree with you there. the
command()
andquery()
methods are nice.This is the underlying mechanism already right now. You could choose to only ever use these in your codebase if you wanted to:
And everything would work fine. If we do end up removing top-level dynamic methods, I think this is what we’d fall back to. But I agree that using string method names isn’t very nice, which is why we’d ideally be able to get away with the dynamic methods.
I don’t think I even thought about
editor.command()
andeditor.query()
already existing like that! This is most likely what I would do in my own code, as long as chaining worked as expected.