Prose Mirror Web Component Step 1

Published on 2024-03-29

I want to create a minimalistic prose-mirror wrapper using a web component. Something that could be used like:

<!-- html -->
<prose-mirror html="<h1>Hello</h1><p>Text</p>" onChangeEvent="change"></prose-mirror>

// js 
document.querySelector('prose-mirror').addEventListener('change', (event) => {
  console.log(event.data.html);
  console.log(event.data.doc);
});

This is a series of steps that I will need to take to get there.

We start by reading the manual of Prose Mirror – which is great.

To follow the first example we need to make the following line of code work:

import {schema} from "prosemirror-schema-basic"

We need to either use npm and a build tool that will process this code and produce a bundle (tools like esbuild, webpack etc.) or we could rely on the ability to load modules via URLs natively in the browser. First thing I usually try is to load the module from one of the ESM CDNs, like esm.sh, jsdeliver.com etc. So, I tried doing this:

import {schema} from "https://esm.sh/prosemirror-schema-basic"
import {EditorState} from "https://esm.sh/prosemirror-state"
import {EditorView} from "https://esm.sh/prosemirror-view"

This works, and we can see in the network tab how the modules are loaded from the CDN. If we do that, we might notice an issue though – "prosemirror-model" is loaded twice. That is already suboptimal, but if we look at the modules closer, we can notice that the versions don’t match. One was `@1.19.3` and another `@1.19.4` – this is no good, I can smell trouble. 
OK, I put a //FIXME comment on top of the imports block describing the issue and go on with the tutorial – soon enough I see the following when pressing "enter" inside the editor: 

> RangeError: Can not convert <> to a Fragment (looks like multiple versions of prosemirror-model were loaded)

Aha. The way esm.sh works is that it rewrites the URLs inside the NPM modules to point at esm.sh paths (since native module loaders need to know where to load the modules from)  – however it needs to know at which versions to point and normally uses each top-dependencies’ package.json to determine that. This results in some modules being loaded twice, as long as at least two packages have a sub-dependency of the same module but at different versions. We don’t want that

There is a nice solution – the standard platform is getting better all the time, and import-maps, a feature that allows us to use absolute module paths like in node, is almost universally supported now

So, we create the following import map and place it in index.html before loading our main script: 

<script type="importmap">
{    
  "imports": {
    "prosemirror-model": "https://esm.sh/*prosemirror-model@1.19.4",
    "orderedmap": "https://esm.sh/*orderedmap@2.1.1",
    "prosemirror-schema-basic": "https://esm.sh/*prosemirror-schema-basic@1.2.2",
    "prosemirror-state": "https://esm.sh/*prosemirror-state@1.4.3",
    "prosemirror-transform": "https://esm.sh/*prosemirror-transform@1.8.0",
    "prosemirror-view": "https://esm.sh/*prosemirror-view@1.33.3",
    "rope-sequence": "https://esm.sh/*rope-sequence@1.3.4",
    "prosemirror-history": "https://esm.sh/*prosemirror-history@1.4.0",
    "prosemirror-keymap": "https://esm.sh/*prosemirror-keymap@1.2.2",
    "w3c-keyname": "https://esm.sh/*w3c-keyname@2.2.8",
    "prosemirror-commands": "https://esm.sh/*prosemirror-commands@1.5.2"    
  }  
}</script>
<script type="module" src="./index.js"></script>

In the module URLs, like "https://esm.sh/*prosemirror-commands@1.5.2", the package names are prefixed with a star character – that means no paths rewriting is happening when esm.sh is printing our modules for us to load – since we rely on import maps and can control exact version loaded this way. Luckily for me, prosemirror is a software of the highest class and as you can see it has very few dependencies and the dependencies it has, have no dependencies themselves. This is so pleasant to work with after modern JS libs that normally pull 900MB of node_modules with many hundreds of dependencies. 

Now we have the import map set up (btw, there is a polyfill for 8% of the browsers that still don’t support it – https://www.npmjs.com/package/es-module-shims, I had to use it as I’m on Safari 16.3, just one minor verson before the support was introduced) and we can move on with the tutorial. We’ll go on in the Step 2 of this series.

Also, there is an excellent write-up on how "es-module-shims" polyfill works – https://guybedford.com/es-module-shims-production-import-maps which I recommend to anyone interested in module loading.