How to restore every aspect of a session including undo-history

262 views
Skip to first unread message

Noah May

unread,
Jun 16, 2022, 10:30:01 PM6/16/22
to Blockly
Hello,

I just started using Blockly with React and I'm having an issue. Blockly is more of a secondary feature of my app, so I have the user click a button that opens a modal to show Blockly. The issue is that the modal component I use completely removes child components from the DOM when closed.

My current workaround is to reinject Blockly into the div every time the modal is opened and to restore the blocks with Blockly.serialization.workspaces.load/.save. This mostly works, but I lose some state like undo-history.

Is there a way to restore a session including undo-history? Or even better, is there a way to restore a Blockly workspace without having to reinject it every time? I believe it is common practice in React for modals to remove child elements instead of hiding them, so using a different modal is not really an option.

Here is psuedo-code for context:

export const BlocklyModal: React.FC = () => {
    const blocklyDiv = useRef<HTMLDivElement>(null);
    const [opened, setOpened] = useState(false);

    useEffect(() => {
        if (opened) {
            Blockly.inject(blocklyDiv.current, {
                toolbox,
                scrollbars: false,
            } as any)
        }
    }, [opened])

    return (
        <Modal
            opened={opened}
            size="90%"
            onClose={() => setOpened(false)}
        >
            <div
                ref={blocklyDiv}
                style={{ height: "90vh", width: "100%" }}
            ></div>
        </Modal>
    );
};


Beka Westberg

unread,
Jun 17, 2022, 10:41:36 AM6/17/22
to blo...@googlegroups.com
Hello!

I think re-injecting it is definitely the right way to go. Reinjecting Blockly recreates all of the DOM elements of the workspace, which seems to match how you explained modals.

For saving the undo history, we don't currently provide a way to do that :/ I think you could get the undo stack, and serialize it to JSON pretty easily. But deserializing it back into the workspace is going to be tricky, because we don't provide a way for you to set the undo stack directly. If you want to give it a shot I would:

1) Create a custom serializer.
2) That uses the getUndoStack method on the workspace.
3) Serializes the events using their toJson method.
4) Deserializes the vents using their fromJson static methods.
3) Fires each of the events using the fireChangeListener method on the workspace, to add them to the undo stack again. (adding to the undo stack happens automatically as a side effect of calling this method)

I hope that gives you some place to start! But I haven't actually tried it so idk how successful it will be :/

If you have any further questions please reply =)
--Beka

--
You received this message because you are subscribed to the Google Groups "Blockly" group.
To unsubscribe from this group and stop receiving emails from it, send an email to blockly+u...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/blockly/e6b74ba0-c9e3-41ce-862f-652e413c9b4fn%40googlegroups.com.

Noah May

unread,
Jun 17, 2022, 3:13:58 PM6/17/22
to Blockly
Sounds good. I think I'll give it a shot :)

Noah

Mark Friedman

unread,
Jun 20, 2022, 1:43:16 PM6/20/22
to blo...@googlegroups.com
Two things to note, since it wasn't obvious to me upon my initial reading of Beka's suggestion. One is that using Beka's approach you aren't directly serializing (or deserializing) any of the block state for the workspace.  You are indirectly serializing it, though, because it is encoded in the events of the undo stack. The firing of the events recreates the state of the workspace and re-establishes the undo stack.

The other thing to note is that you'll probably want to set workspace.MAX_UNDO to Infinity before any blocks are added to the workspace by the user. Otherwise, the default MAX_UNDO is 1024 and you might not capture all the events that have occurred.

-Mark


Beka Westberg

unread,
Jun 21, 2022, 10:13:46 AM6/21/22
to blo...@googlegroups.com
Hello again :D

> One is that using Beka's approach you aren't directly serializing (or deserializing) any of the block state for the workspace... The firing of the events recreates the state of the workspace and re-establishes the undo stack.

In the approach I posted above you actually do have to serialize the block state as well as the undo-stack state haha.

Firing events is different from running events. Firing events is like an observer pattern, while running events is like a command pattern. So firing the event will notify event listeners of what happened, and add events to the undo stack, but the *effect* of the event won't actually happen.

For example, if you deserialize a BlockCreate event, and then fire it in the workspace, all listeners will be notified that the create event happened, and the event will be added to the undo stack, but no blocks will be created nor deleted.

So you also need to serialize the state of the blocks and variables in your workspace. If you add the undo stack serializer as a custom serializer, you can call Blockly.serialization.workspaces.save, and it will serialize everything.

Best wishes,
--Beka

Mark Friedman

unread,
Jun 21, 2022, 1:03:29 PM6/21/22
to blo...@googlegroups.com
Thanks for that clarification, Beka.  I hadn't caught the subtlety that fireChangeListener doesn't run the events.  That said, I think there is potentially a problem with the approach that you mentioned, which is that the firing of the events can still cause side-effects that presumably have already been applied and are reflected in the serialized (and therefore unserialized) workspace.  Applying them again may cause unexpected or even erroneous changes to the workspace.  Presumably the developer could deal with this in their own defined change listeners (e.g. by setting some dynamic flag) but IIRC there are some change listeners that are created by the system itself.  I'm not sure how to deal with those, unless they can be proven to be "safe".  So maybe there is still a need for the approach of setting MAX_UNDO to Infinity and applying all the events to an empty workspace.  In the long run, though, perhaps adding methods for explicitly adding to the undo stack might be cleaner. 

-Mark


Beka Westberg

unread,
Jun 22, 2022, 10:22:24 AM6/22/22
to blo...@googlegroups.com
Yeah that's a good point. I don't think there are any event listeners that core blockly adds that would cause erroneous side effects (I briefly looked through all of them). But there's always that possibility. Wrt a developer's own change listeners, I think the easiest thing to do is just not add them until after you've deserialized the workspace. In pseudo-javascript:

```
var workspace = Blockly.inject('blocklyDiv', {});
Blockly.serialization.workspaces.load(json, workspace);
workspace.addChangeListener(myChangeListener);
```

But yeah, definitely agree a specific API for this would be better haha. If anyone wants to file that feature request you can do so here. I'm very curious to see how any experimenting goes though!

Best wishes,
--Beka

Mark Friedman

unread,
Jun 22, 2022, 3:01:16 PM6/22/22
to blo...@googlegroups.com
I think you also have to deal with any onchange() methods of your blocks, right, since they are implicitly added as change listeners?

-Mark


Beka Westberg

unread,
Jun 22, 2022, 4:17:09 PM6/22/22
to blo...@googlegroups.com
Very true! If that becomes a problem you could try setting the priority of your undo stack serializer so that the undo stack is deserialized before the blocks are.

Best wishes,
--Beka

Mark Friedman

unread,
Jun 22, 2022, 4:36:38 PM6/22/22
to blo...@googlegroups.com
Wow, you thought of everything, Beka!  ;-)

-Mark


Noah May

unread,
Jul 7, 2022, 4:17:58 PM7/7/22
to Blockly
I tried to get something working. Unfortunately, I wasn't successful.

The first hurdle is that every event class implements their own to/fromJson methods, deserializing would require a large switch statement on `event.type`. For now though, I tried only working with BlockMove events. Funnily enough, `event.recordUndo` does not seem to be serialized, so we have to manually set it to true. And we end up with code looking like this:

```
Blockly.serialization.registry.register("undo-history", {
    save(workspace) {
        return workspace
            .getUndoStack()
            .filter((event) => event.type === Blockly.Events.BLOCK_MOVE)
            .map((event) => event.toJson());
    },
    load(state: any[], workspace) {
        for (let eventState of state) {
            const event = new Blockly.Events.BlockMove(workspace.getBlockById(eventState.blockId) as any);
            event.fromJson(eventState);
            event.recordUndo = true;
            workspace.fireChangeListener(event);
        };
    },
    clear(workspace) {},
    priority: 10,
});
```

Trying to undo with ctrl-z, however, throws an error `Workspace is null. Event must have been generated from real Blockly events.` Manually setting `event.workspaceId = workspace.id` throws `Cannot read properties of undefined (reading 'nextConnection') at BlockMove.run()`

Preserving undo-history is not a big priority for my project right now, so I'm going to have to leave it at that. Perhaps someone else can get it working and contribute it to Blockly core.

Also, this might already be fixed in the mass conversion to typescript, but the return type of `Event.toJson` should be `object` (or `any`), not `Object`. The first (lowercase O) is a generic object with unknown properties, while the second (uppercase O) is the default prototype of any new object with methods like `.toString()`. It's a subtle difference, but an important one :)

- Noah

Mark Friedman

unread,
Jul 7, 2022, 6:20:20 PM7/7/22
to blo...@googlegroups.com
On Thu, Jul 7, 2022 at 1:18 PM Noah May <noahmo...@gmail.com> wrote:
I tried to get something working. Unfortunately, I wasn't successful.

The first hurdle is that every event class implements their own to/fromJson methods, deserializing would require a large switch statement on `event.type`.

FYI, you can use Blockly.Events.fromJson() for this and avoid the large switch statement.

 
For now though, I tried only working with BlockMove events. 
Funnily enough, `event.recordUndo` does not seem to be serialized, so we have to manually set it to true.

AFAICT, recordUndo is serialized by event.toJson.  See, for example, the source code for Blockly.Events.BlockMove.toJson().  I'm not sure what's going on there.  In any case you should be safe to set it explicitly as you are doing.

And we end up with code looking like this:

```
Blockly.serialization.registry.register("undo-history", {
    save(workspace) {
        return workspace
            .getUndoStack()
            .filter((event) => event.type === Blockly.Events.BLOCK_MOVE)
            .map((event) => event.toJson());
    },
    load(state: any[], workspace) {
        for (let eventState of state) {
            const event = new Blockly.Events.BlockMove(workspace.getBlockById(eventState.blockId) as any);
            event.fromJson(eventState);
            event.recordUndo = true;
            workspace.fireChangeListener(event);
        };
    },
    clear(workspace) {},
    priority: 10,
});
```

Trying to undo with ctrl-z, however, throws an error `Workspace is null. Event must have been generated from real Blockly events.` Manually setting `event.workspaceId = workspace.id` throws `Cannot read properties of undefined (reading 'nextConnection') at BlockMove.run()`

Blockly.Events.fromJson()  takes the workspace as one of its arguments and will set the id for you.  I don't know if that will solve all of the problems you cite, but it might get you closer.

Hope this helps, either you or the next person that wants to try.

-Mark
 

Noah May

unread,
Jul 8, 2022, 10:16:55 PM7/8/22
to Blockly
I tried using `Blockly.Events.fromJson()`, and that made it simpler, but it runs into some of the same issues.

> AFAICT, recordUndo is serialized by event.toJson.

It's being serialized, but only if it's false, which makes no sense to me tbh since the default is false anyways. That requires a bug report perhaps?

Anyways, using `Blockly.Events.fromJson()` and manually setting `event.recordUndo = true` still results in the TypeError: `Cannot read properties of undefined (reading 'nextConnection') at BlockMove.run()`.

Again, I don't have too much time to dig into this, but I'll keep the branch around in case someone has another quick suggestion.

Noah May

unread,
Aug 3, 2022, 1:35:18 AM8/3/22
to Blockly
This issue is already being tracked in github. There's also some rationale listed there for why the serialized events are not working. https://github.com/google/blockly/issues/1266
Reply all
Reply to author
Forward
0 new messages