Return to page

BLOG

Integrating VSCode editor into H2O Wave

 headshot

By Martin Turoci | minute read | August 18, 2022

Blog decorative banner image

Let’s have a look at how to provide our users with a truly amazing experience when we need to allow them to edit pieces of code or configuration.

We will use one of the most popular and well-known code editors called Monaco editor which powers VSCode. The resulting app will have the editor on the left side and a markdown card on the right to showcase how to actually capture the data and communicate with Wave.

Current state

H2O Wave has a built-in mechanism for editing a multiline text via its ui.textbox component, but it has significant downsides:

  • No syntax highlighting.
  • No auto-completion.
  • Regular font instead of monospaced which tends to be more readable.
  • No find/replace functionality and other editor goodies.
vscode screenshot vscode screenshot
Existing textbox — not suited for code blocks.

 

General Approach

The app consists of 2 parts:

  1. Python part — regular Wave app.
  2. Javascript part — Monaco editor configuration.

The Javascript part

All the code is loaded using require.js, but you can consider it an implementation detail. The only Wave-related thing to note is events. We fire 2 kinds:

  • Save event — when the user hits CMD or CTRL + S, used for updating our left-hand side
  • Change event — when the user types something, used to mark the editor card as dirty using 

const baseUrl = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.33.0/min/vs'

// UNIMPORTANT: RequireJS paths config for monaco editor.

require.config({

  paths: {

    'vs': baseUrl,

    'vs/language': `${baseUrl}/language`,

  }

})

// UNIMPORTANT: Web worker registration for monaco editor.

window.MonacoEnvironment = {

  getWorkerUrl: function (workerId, label) {

    return `data:text/javascript;charset=utf-8,${encodeURIComponent(`

      importScripts('${baseUrl}/base/worker/workerMain.js');`

    )}`

  }

}

// Load the editor javascript.

require(['vs/editor/editor.main'], async () => {

  // Render the editor into our existing div with ID "editor".

  const editor = monaco.editor.create(document.getElementById('editor'), {

    language: 'python',

  })

  // Use cmd (mac) or ctrl + S to save content.

  editor.addAction({

    id: 'save-content',

    label: 'Save',

    keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],

    // Emit q.events.editor.save event on CMD+S.

    run: editor => window.wave.emit('editor', 'save', editor.getValue())

  })

  // When the editor text changes, emit q.events.editor.change event

  // to mark dirty state.

  editor.onDidChangeModelContent(() => window.wave.emit('editor', 'change', true))

})

app.js

 

In the javascript file above, we start with configuring our script paths, then load the editor (on line 17). Here we provide a div element (to be rendered by Wave) based on its unique id editor and configure it to use syntax highlighting for python. See this demo to explore all the available languages. If you want to tweak your editor a bit more, see all the available configuration options.

Once the editor has been created, we want to also setup up listeners from which we will emit Wave events using window.wave.emit function. These are located within editor.addAction and editor.onDidChangeModelContent.

 

The Python part

The first thing we need to create is a single HTML div element that will serve as a container into which Monaco renders its magic. We can use ui.markup_card as it’s designed for this exact purpose — rendering raw HTML. We also give the div a unique ID to easily select it in our JS file. Lastly, we want the editor to take up all the available card space so a few extra styles are necessary.

Then we just render a ui.markdown_card to display saved text and demonstrate how can Wave and Monaco communicate.

from h2o_wave import main, app, Q, ui

 

# UNIMPORTANT: Helper function to read the file.

def read_file(p: str) -> str:

    with open(p, encoding='utf-8') as f:

        return f.read()

 

 

@app('/')

async def serve(q: Q):

    if not q.client.initialized:

        q.page['editor'] = ui.markup_card(

            box='1 1 6 10',

            title='Editor',

            # Render a container div and expand it to occupy whole card.

            content='''

<div id="editor" style="position:absolute;top:45px;bottom:15px;right:15px;left:15px"/>

'''

        )

        q.page['text'] = ui.markdown_card(box='7 1 5 10', title='Text', content='')

        # Make sure to render the div prior to loading Javascript.

        await q.page.save()

 

        # Download the necessary javascript and render the Monaco editor.

        q.page['meta'] = ui.meta_card(

            box='',

            # Download external JS loader script.

            scripts=[ui.script('''

https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.33.0/min/vs/loader.min.js

''')],

            script=ui.inline_script(

              # Read our JS file and pass it as a string.

              content=read_file('app.js'),

              # Only run this script if "require" object is present

              # in browser's window object.

              requires=['require'],

              # Only run this script if our div container

              # with id "monaco-editor" is rendered.

              targets=['editor']

            ),

        ) 

        q.client.initialized = True

 

    await q.page.save()

app.py

 

Handling the events then is a piece of cake.

if q.events.editor:

    if q.events.editor.save:

        q.page['text'].content = q.events.editor.save

        q.page['editor'].title = 'Editor'

    elif q.events.editor.change:

        # Show "dirty" state by appending an * to editor card title.

        q.page['editor'].title = '*Editor'

Event handling.

 

The result

Using less than 100 total lines of code you get syntax highlighting and rich editing capabilities almost for free. Not bad at all I would say 🙂

result result
Wave app with Monaco editor.

from h2o_wave import main, app, Q, ui

 

# UNIMPORTANT: Helper function to read the file.

def read_file(p: str) -> str:

    with open(p, encoding='utf-8') as f:

        return f.read()

 

 

@app('/')

async def serve(q: Q):

    if not q.client.initialized:

        q.page['editor'] = ui.markup_card(

            box='1 1 6 10',

            title='Editor',

            # Render a container div and expand it to occupy whole card.

            content='''

<div id="editor" style="position:absolute;top:45px;bottom:15px;right:15px;left:15px"/>

'''

        )

        q.page['text'] = ui.markdown_card(box='7 1 5 10', title='Text', content='')

        # Make sure to render the div prior to loading Javascript.

        await q.page.save()

 

        # Download the necessary javascript and render the actual Monaco editor.

        q.page['meta'] = ui.meta_card(

            box='',

            # Download external JS loader script.

            scripts=[ui.script('''

https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.33.0/min/vs/loader.min.js

''')],

            script=ui.inline_script(

              # Read our JS file and pass it as a string.

              content=read_file('app.js'),

              # Only run this script if "require" object is present

              # in browser's window object.

              requires=['require'],

              # Only run this script if our div container

              # with id "monaco-editor" is rendered.

              targets=['editor']

            ),

        ) 

        q.client.initialized = True 

  

    if q.events.editor:

        if q.events.editor.save:

            q.page['text'].content = q.events.editor.save

            q.page['editor'].title = 'Editor'

        elif q.events.editor.change:

            # Show "dirty" state by appending an * to editor card title.

            q.page['editor'].title = '*Editor'

 

    await q.page.save()

 headshot

Martin Turoci