k-hole is the game development and design blog of kyle kukshtel
subscribe to my newsletter to get posts delivered directly to your inbox

game-data-editor-3


Date: 9/20/20 Tags: Tools, Svelte, Data, VSCode Permalink: /game-data-editor-vscode-part-3 Summary: Getting data into Svelte from VSCode’s virtual

September 20, 2020

Date: 9/20/20 Tags: Tools, Svelte, Data, VSCode Permalink: /game-data-editor-vscode-part-3 Summary: Getting data into Svelte from VSCode’s virtual document Title: Building a Game Data Editor in Visual Studio Code Part 3: Getting Data into Svelte —

This is part of a series of posts where I discuss building a game data editor inside of Visual Studio Code.

Part 1: Why?
Part 2: Custom Editors, Webviews, and Svelte
Part 3: Getting Data into Svelte
Part 4: Editing Data in Svelte

sveltevscode

Introduction

In the last post, I talked about getting Svelte setup to build VS Code Webviews with, specifically for use with VS Code’s Custom Editor functionality. What I didn’t explain in that post was how to then actually get the data of the document you were previewing into the webview itself. I’m going to focus on that in this post, and by the end you should have a rough toolkit that allows you to preview your data in VS Code with Svelte!

What Data?

To reiterate what a Custom Editor in Code does, is that it allows you to invoke a webview in place of the native text editor. However, because we’re making an editor, we obviously want some ability to edit right? When we left off the previous tutorial, this is what we saw:

image-20200915122221172

Looking at the raw example.cscratch data, we see this:

image-20200915122315200

What we want to be able to do is mimic the functionality of the previous Custom Editor example, where that raw data was able to be displayed in a formatted way inside of the webview. We’ll also talk about editing the displayed data in the next post, but for now lets focus on just getting the data in there at all.

Getting Data into Svelte

So Step 1, how do we get this data into Svelte? I’ll also be frank here: I tried a lot of different ways to do this, and I still think there are a few different valid ways, but I’m just going to show you the method I’m using that seems to work well. I couldn’t full understand the lifecycle of virtual document creation in Code’s webviews, as well as how and when the HTML was being rebuilt by Svelte, so I settled on a solution that has Svelte essentially requesting the data once it’s loaded into the DOM via onMount. Trying anything like serving the data inside the HTML didn’t really work, so instead I assumed that once Svelte has loaded, it can request the data and display correctly.

Step 1: Tell Code that Svelte has loaded

Svelte has an onMount lifecycle hook that is called after the component is first rendered to the DOM. Which, per my statement above, sounds like a great time to request data! In the onMount call, I’ll send a message to the extension to say that Svelte has loaded. Here’s the updated App.svelte (I also got rid of the old apitest” event and cleaned up the sample, getting rid of css):

<script>
    //add in onMount
    import { onMount } from 'svelte';
    //attach a function to onMount
    onMount(() => {
        vscode.postMessage({
            type: 'init-view',
        });
    });
</script>

<main>
    <h1>Hello World!</h1>
</main>

Step 2: Listen for event in Extension

Now that we’re emitting this init-view from Svelte, we need to catch it on the extension side and do something. Our goal here is to basically grab the document’s data, and then send that data back to Svelte. Here’s the code (added in catScratchEditor.ts):

webviewPanel.webview.onDidReceiveMessage(e => {
    switch (e.type) {
        case 'add':
            this.addNewScratch(document);
            return;
        case 'delete':
            this.deleteScratch(document, e.id);
            return;
        case 'init-view': //added this route
            webviewPanel.webview.postMessage({
                type: 'init',
                text: document.getText(),
            });
            return;
   }
});

So when the extension receives the message the init the view, it just grabs the current state of the virtual document through document.getText() and then sends that back to the webview. We’ll get to message handling in webviews in a second, but two notes.

One, as part of this code I also now remove the call in catScratchEditor.ts to updateWebview() at the end of the resolveCustomTextEditor callback. You’ll see a bit more in the next section about why, but basically I want to make sure I control the data flow through the program. So now we don’t try to update anything until Svelte is ready.

Multiple Data Types, One Editor

Two, this init-view event has some really interesting implications on the rest of our program. Right now we’re just sending the data of the document, but we can send anything else as part of this event, like… maybe the document’s actual type? Because we are using something like Svelte for all our webview rendering vs. needing to rely on specific HTML strings, and Svelte has the ability to conditionally display elements, we can actually get rid of the need to have one editor per data type (provided the data types themselves are roughly the same base representation). We can read in the extension of our file here, pass the type to Svelte, and have Svelte handle any differences! The reason we’d want this is because there is essentially nothing that we actually need the extension itself to do anymore, as everything is now being handled in Svelte..

Because the sample is meant to represent both binary data editors (.pawDraw) and text-based ones (.cscratch), combining the editors in this case is a bad idea. However, imagine we wanted to make a Dog Bark file (.dbark), which was also text based like .cscratch, we could easily have our editor support this file type by updating the manifest. Let’s do that (in catScratchEditor.ts )!

webviewPanel.webview.onDidReceiveMessage(e => {
    switch (e.type) {
        case 'add':
            this.addNewScratch(document);
            return;
        case 'delete':
            this.deleteScratch(document, e.id);
            return;
        case 'init-view':
            //split the filename and pop the last element for the extension
            let dt = document.fileName.split('.').pop(); 
            //dt will only ever be either cscratch or dbark, because this editor (catScratchEditor.ts) is
            //only opened when one of those files is selected (per the package.json)
            webviewPanel.webview.postMessage({
                type: 'init',
                text: document.getText(),
                dataType: dt
            });
            return;
   }
});

So above, we read in the extension of the file, and pass it as another parameter to the webview message. What we also want to do here is update the package.json to open this editor only when either filetype is opened. In the customEditors section of our contributes, in the catCustoms.catScratch section, we can add our new file type to the filename pattern section, making it so that both .cscratch and .dbark files use the catScratchEditor.

We update this:

{
    "viewType": "catCustoms.catScratch",
    "displayName": "Cat Scratch",
    "selector": [
        {
            "filenamePattern": "*.cscratch"
        }
    ]
},

To be this:

{
    "viewType": "catCustoms.catScratch",
    "displayName": "Cat Scratch",
    "selector": [
        {
            "filenamePattern": "*.{cscratch,dbark}"
        }
    ]
},

In reality, you’d want to update the names of these files and their provides to represent that they are the routes for multiple data types, but for the sake of the tutorial I’m keeping the names constant.

I’ll also add in the first ever .dbark file into my project, and give it some data:

image-20200915172956895

Let’s run the program just to make sure our .dbark file is recognized now:

image-20200915173206415

Perfect!

Step 3: Catch Init Event in Svelte

Things definitely start to get a bit sticky here, and I fully admit again this may not be the best way to do things. The reason things are difficult is because, inside Svelte now, you are juggling lots of different states of things that all need to be in sync. You’ve got:

  1. The actual state of the text file. What’s in it, etc.

  2. The current state of the virtual document”, the representation of your .cscratch/.dbark file on Code’s side.

  3. The potential edited state of the virtual document”, holding any edits you made that deviate from the state of the document since last save

  4. The state of the data that Svelte holds (as json in this case) that represents the state of the virtual document at a given moment.

Messing up one of these will make your editor not really functional, so they all need to be accounted for and mirror each other where relevant. I’m sure there is a better way to do what I’m doing here, but I’ve essentially opted to try and bootstrap the data flow in a way that makes sense to me and is easy to trace. So let’s dive in!

Once we catch that init-view event in the extension, we’re using webviewPanel.webview.postMessage to send the current document state back to Svelte. However, our Svelte template code is not yet setup to receive messages from the webview. We can do this in Svelte by adding a this small line of code:

<svelte:window on:message={windowMessage}/>

To the markup section of our App.svelte component:

<script>
    import { onMount } from 'svelte';
    onMount(() => {
        vscode.postMessage({
            type: 'init-view',
        });
    });
    //window event handler
    function windowMessage(event) {}
</script>

<svelte:window on:message={windowMessage}/>
<h1>Hello World!</h1>

This is functionality Svelte offers to allow us to receive any messages from the window (/webview) and route them to a handler function (in this case, windowMessage).

Inside the function, we’ll now catch both the init event we setup, as well as the update event called by the extension when the document changes. Here’s the whole code first, then I’ll walk through each bit:

let dataType = "";
let jsonData = {};

function windowMessage(event) {
    const message = event.data; // The json data that the extension sent
    switch (message.type) {
        case 'init':
            //the extension is sending us an init event with the document text
            //note: this is the document NOT the state, the state takes precendece, so if any state exists use that instead
            const state = vscode.getState();
            if (state) {
                //we push this state from the vscode workspace to the JSON this component is looking at
                jsonData = JSON.parse(state.text);
            }
            else {
                //use the state data
                jsonData = JSON.parse(message.text);
            }
            dataType = message.dataType;
            return;
        case 'update':
            //assign data
            const text = message.text;
            jsonData = JSON.parse(text);
            // assign state
            vscode.setState({ text });

            return;
    }
}

First we define two variables. One to hold the dataType, used to switch what we’re showing in Svelte, and one to hold the actual parsed JSON data.

let dataType = "";
let jsonData = {};

You can see here how, if you had two different files that weren’t represented by the same general filetype (json, csv, etc.), that you may want a different editor for them.

Init Route

const state = vscode.getState();

Inside the actual callback function, inside of the init route, we first get the state of the virtual document. Remember, this editor is invoked upon opening the requisite file, but because Code can have multiple windows open for the same file, it’s possible that we’re opening up a file that current has working changing in its virtual document. This allows us to retrieve those changes before doing anything.

if (state) {
    jsonData = JSON.parse(state.text);
}
else {
    jsonData = JSON.parse(message.text);
}

If there is any pre-existing state, we just assign the jsonData to be that state. Otherwise, we use the data passed to us as the state.

dataType = message.jsonType;

Lastly we update our dataType. Note this is only called once, on init.

Update Route

const text = message.text;
jsonData = JSON.parse(text);
vscode.setState({ text });

For the update route, because the document is only calling this if it was updated, we can just accept the sent data and assign that to the state. We grab the sent data, parse it into json, and assign the message.text as the state of our document.

Step 4: Make sure it’s working!

Theoretically our data is now coming into Svelte, but let’s make sure. We can add a small line in the markup section to just spit out whatever json we may or may not have. Here’s the full App.svelte after that change:

<script>
    //add in onMount
    import { onMount } from 'svelte';
    //attach a function to onMount
    onMount(() => {
        vscode.postMessage({
            type: 'init-view',
        });
    });

    let dataType = "";
    let jsonData = {};

    function windowMessage(event) {
        const message = event.data; // The json data that the extension sent
        switch (message.type) {
            case 'init':
                //the extension is sending us an init event with the document text
                //note: this is the document NOT the state, the state takes precendece, so if any state exists use that instead
                const state = vscode.getState();
                if (state) {
                    //we push this state from the vscode workspace to the JSON this component is looking at
                    jsonData = JSON.parse(state.text);
                }
                else {
                    //use the state data
                    jsonData = JSON.parse(message.text);
                }
                dataType = message.dataType;
                return;
            case 'update':
                //assign data
                const text = message.text;
                jsonData = JSON.parse(text);
                // assign state
                vscode.setState({ text });
                return;
        }
    }
</script>

<svelte:window on:message={windowMessage}/>
<h1>Hello World!</h1>
<pre>{JSON.stringify({jsonData},null,2)}</pre>

If we run it, we should expect to see the Hello World text, and then, depending on the open editor, some data dumped below it. Let’s see…

image-20200915180428220

image-20200915180441366

Awesome! It’s working for both scratches and barks! We can also see that the .pawDraw editor is also still intact:

image-20200915180547798

Step 4.5: Editor Switching

I want to focus on the .cscratch file from now on, but before we say goodbye to the .dbark file, let’s give it its own special editor. Using the #if directive in Svelte, we can now easily change what is being displayed by switching between data types once the data is loaded:

<svelte:window on:message={windowMessage}/>
{#if dataType == ""}
    <p>Loading</p>
{:else if dataType === "dbark"}
    <h1>WOOF</h1>
{:else if dataType === "cscratch"}
    <h1>MEOW</h1>
{/if}
<h1>Hello World!</h1>
<pre>{JSON.stringify({jsonData},null,2)}</pre>

Note above that we can also catch the case where the dataType isn’t loaded yet, meaning the init call hasn’t finished. This is one of those areas where I’m assuming there is a better way to do this, but for now, 🤷‍♂️.

Running the extension again:

image-20200915182013983

image-20200915182028256

Great! Now, so long .dbark, I barely even knew you.

Next Steps

So we’ve now got data flowing into Svelte. From here you could easily hook up the current data from the .cscratch file to Svelte components and so on, but you’ll quickly get into a position where you’ll probably want to now only show the current data, but also edit it! Editing the data is the focus of the next part of this tutorial, which you can find here.

This is part of a series of posts where I discuss building a game data editor inside of Visual Studio Code.

Part 1: Why?
Part 2: Custom Editors, Webviews, and Svelte
Part 3: Getting Data into Svelte
Part 4: Editing Data in Svelte


Date
September 20, 2020




subscribe to my newsletter to get posts delivered directly to your inbox