Skip to content
On this page

Monaco Editor Notes

Monaco Editor is the code editor that powers VSCode, and it can be used in web applications as well.

I tried adding it to some of my products, here are some notes on using Monaco Editor, especially using in browser with TypeScript.

Importing in browser

Docs - https://github.com/microsoft/monaco-editor/blob/main/docs/integrate-amd.md

html
<script src="https://unpkg.com/[email protected]/min/vs/loader.js"></script>
<script>
  window.monacoPromise = new Promise((resolve, reject) => {
    require.config({ paths: { vs: 'https://unpkg.com/[email protected]/min/vs' } });

    // Before loading vs/editor/editor.main, define a global MonacoEnvironment that overwrites
    // the default worker url location (used when creating WebWorkers). The problem here is that
    // HTML5 does not allow cross-domain web workers, so we need to proxy the instantiation of
    // a web worker through a same-domain script
    window.MonacoEnvironment = {
      getWorkerUrl: function(workerId, label) {
        return `data:text/javascript;charset=utf-8,${encodeURIComponent(`
self.MonacoEnvironment = {
  baseUrl: 'https://unpkg.com/[email protected]/min/'
};
importScripts('https://unpkg.com/[email protected]/min/vs/base/worker/workerMain.js');`
        )}`;
      }
    };
    require(['vs/editor/editor.main'], async () => {
      const codebox = document.querySelector('.codebox');
      const editor = monaco.editor.create(codebox, {
        model: null,

        lineNumbers: "on",
        minimap: { enabled: false },
        automaticLayout: true,
        roundedSelection: false,
        scrollBeyondLastLine: false,
        readOnly: false,
        theme: "vs-dark",
        'semanticHighlighting.enabled': true
      });
      window.editor = editor;
      resolve(editor);
    });
  });
</script>

Create a model

javascript
const absUrl = (new URL(script, iframe.src))
const uri = (new monaco.Uri('file', '', absUrl.pathname, absUrl.search, absUrl.hash));
const content = await (await fetch(absUrl.href)).text()
const model = monaco.editor.createModel(content, undefined, uri);
saveFile(uri.toString(), content);
model.onDidChangeContent(()=>{
  let value = model.getValue()
  const savedVal = getFile(uri.toString());
  if(value === savedVal) tab.classList.remove('codefile-unsaved');
  else tab.classList.add('codefile-unsaved');
})

Typescript

Compiler options

javascript
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
    module: monaco.languages.typescript.ModuleKind.ESNext,
    // target: monaco.languages.typescript.ScriptTarget.ES2016,
    target: monaco.languages.typescript.ScriptTarget.ES2020,
    allowNonTsExtensions: true,
    moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
    esModuleInterop: true,
});

Go to definition (Cmd+Click)

Reference - https://github.com/microsoft/monaco-editor/issues/2000

javascript
const editorService = editor._codeEditorService;
const openEditorBase = editorService.openCodeEditor.bind(editorService);
editorService.openCodeEditor = async (input, source) => {
    const result = await openEditorBase(input, source);
    if(result !== null || !source.setFileUri) return result
    let model = monaco.editor.getModel(input.resource);
    source.setModel(model);
    // source.setFileUri(input.resource.toString());
  
    source.setPosition({
        lineNumber: input.options.selection.startLineNumber,
        column: input.options.selection.startColumn
    });
    source.revealRangeInCenterIfOutsideViewport({
        startLineNumber: input.options.selection.startLineNumber,
        endLineNumber: input.options.selection.endLineNumber,
        startColumn: input.options.selection.startColumn,
        endColumn: input.options.selection.endColumn
    })
    return result; // always return the base result
};

Typescript worker / transpile to javascript

javascript
let tsWorkerClient = null;
const getTsWorker = async () => {
    // await window.monacoPromise
    if(tsWorkerClient) return tsWorkerClient;
    const worker = await monaco.languages.typescript.getTypeScriptWorker();
    tsWorkerClient = await worker();
    return tsWorkerClient;
}
window.getCompiledJs = async (uri)=>{
    const client = await getTsWorker()
    const diagnostics = await client.getSyntacticDiagnostics(uri);
    if(diagnostics.length > 0) { // syntax error
        const diagnostics2 = await client.getSemanticDiagnostics(uri);
        console.warn('Syntactic diagnostics found:', diagnostics);
        console.warn('Semantic diagnostics found:', diagnostics2);
        return;
    }
    const result = await client.getEmitOutput(uri)
    if(result.outputFiles.length > 0){
        return result.outputFiles[0]
    }
}

Node modules

Add typescript lib

javascript
const path = 'file:///node_modules/typescript/lib/lib.d.ts'
const text = await (await fetch('https://cdn.jsdelivr.net/npm/[email protected]/lib/lib.d.ts')).text()
monaco.languages.typescript.typescriptDefaults.addExtraLib(text, path)

Import tar package

Some ad-hoc code for loading tgz packages from npm registry, extracting them, and registering the files in Monaco Editor. Works for simple use-cases like typescript autocomplete, open type/source, jsdoc upto some levels. Doesn't handle dev/peer dependencies, custom registries, etc

Links

html
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/dist/untar.min.js"></script>
<script>
const monacoPrefix = 'file:///node_modules/'
const maxLevel = 2
const fileLoadState = {
  packages: new Map(), // name to version
  files: new Map(),
}
function registerFile(path, text){
  path = monacoPrefix + path
  if(fileLoadState.files.has(path)){
    console.warn('Replacing existing file', path, 'with new content');
  }
  fileLoadState.files.set(path, text)
  monaco.languages.typescript.typescriptDefaults.addExtraLib(text, path);
}
async function loadPackageFromTarGz(packageName, version1 = 'latest', level = 0) {
  console.log('Loading package', packageName, 'version', version1, 'at level', level);
  if(fileLoadState.packages.has(packageName)) {
    const version = fileLoadState.packages.get(packageName).version ?? '0.0.0';
    if(version1 !== 'latest' && version1 !== version) {
      console.warn('Package already loaded with another version', packageName, 'current:', version, 'requested:', version1);
    }
    return;
  }
  if(level > maxLevel) {
    console.warn('Max level reached, skipping', packageName, 'at level', level);
    return;
  }
  
  // package.json - https://registry.npmjs.org/threepipe/latest
  // tgz - https://registry.npmjs.org/threepipe/-/threepipe-0.0.52.tgz
  // tgz - https://registry.npmjs.org/@threepipe/plugin-tweakpane/-/plugin-tweakpane-0.6.2.tgz
  // json - https://registry.npmjs.org/@threepipe/plugin-tweakpane/latest
  
  const npmBase = 'https://registry.npmjs.org/' + packageName
  const packageNameEnd = packageName.split('/').pop()

  let tarball
  if(version1 === "latest") {
    let url = npmBase + '/latest'
    const jsonText = await fetch(url).catch(e => null)
    if (!jsonText?.ok) {
      console.error('Unable to load package', packageName)
      return
    }
    const packageJson = await jsonText.json()
    const version = packageJson.version;
    tarball = npmBase + '/-/' + packageNameEnd + '-' + version + '.tgz';
  }else {
    if(version1.startsWith('http'))
      tarball = 'https://cors-proxy.r2cache.com/' + version1;
    else
      tarball = npmBase + '/-/' + packageNameEnd + '-' + version1 + '.tgz';
  }

  let files
  try {
    const cache = await caches.open('node-modules-tarball');
    const cacheKey = tarball.replace('https://', 'cc-')
    const cached = await cache.match(cacheKey);
    if(cached) files = await cached.arrayBuffer().then(buf => untar(buf));
    else {
      const buffer = await fetch(tarball).then(r => r.arrayBuffer())
      const decompressed = pako.inflate(buffer);
      await cache.put(cacheKey, new Response(decompressed.buffer));
      files = await untar(decompressed.buffer);
    }
  }catch (e) {
    console.error(e)
    console.error('Unable to load tarball for', packageName, version1);
    return
  }

  let packageJsonS = ''
  const contents = []
  let hasTypes = false
  for (const file of files) {
    try {
      // name "package/dist/assetmanager/import/ZipLoader.d.ts"
      const pp = packageNameEnd + '/'
      // Adjust to remove leading "package/" if needed
      const name = file.name.startsWith('package/') ? file.name.slice(8) :
              file.name.startsWith(pp) ? file.name.slice(pp.length) : file.name;
      if (name === 'package.json') {
        packageJsonS = new TextDecoder().decode(file.buffer);
      }

      else if (true
              || name.startsWith('dist/')
              || name.startsWith('index.')
              || name.endsWith('.ts')
              // || name.endsWith('.map')
              || name.endsWith('.json')
              || name.endsWith('.js')
      ) {
        const content = new TextDecoder().decode(file.buffer);
        const virtualPath = name;
        // fileLoadState.files.set(virtualPath, content)
        contents.push([virtualPath, content]);
      }

      if(name === 'index.d.ts') hasTypes = name
    }catch(e) {
      console.error('Error while adding file', file.name.replace(/^package\//, packageName), e);
    }
  }
  // console.log(contents)

  if(packageJsonS){
    const packageJson = JSON.parse(packageJsonS);
    fileLoadState.packages.set(packageName, packageJson);
    registerFile(packageName + '/package.json', packageJsonS);
    const typesFile = packageJson.types || packageJson.typings;
    if (typesFile && !hasTypes) {
      const types = `export * from './${typesFile}';`// + `\nexport { default } from './${typesFile}';`;
      // const types = `export * from './dist/index';`// + `\nexport { default } from './${typesFile}';`;
      // fileContents[typesPath] = types
      registerFile(packageName + '/index.d.ts', types);
    }else if(typesFile && hasTypes !== typesFile) {
      console.error('Package', packageName, 'has types file', typesFile, 'but also has index.d.ts file', hasTypes, 'skipping', packageName);
    }
    for (const [key, value] of Object.entries(packageJson.dependencies || {})) {
      let version = value
      if(value[0] === '^') version = value.slice(1);
      else if(value[0] === '~') version = value.slice(1);
      else if(value[0] === '>') version = value.slice(1);
      if(!version.includes('.')) version = 'latest'
      await loadPackageFromTarGz(key, version, level + 1)
    }
    for (const content of contents) {
      registerFile(packageName + '/' + content[0], content[1]);
    }
  }else {
    console.error('No package.json found in tarball for', packageName, version1, 'skipping', tarball, contents);
  }
}
window.loadPackageFromTarGz = loadPackageFromTarGz;

</script>

Import package from importmap

javascript
const importMap = doc.querySelector('script[type="importmap"]')
const imports = importMap ? JSON.parse(importMap.textContent || '{}').imports || {} : {}

for (let key of Object.keys(imports)) {
    await loadPackageFromTarGz(key)
}

Assuming importmap looks like this:

html
<script type="importmap">
{
    "imports": {
      "threepipe": "...",
      "@threepipe/plugin-tweakpane": "..."
    }
}
</script>

Handle save

javascript
// in general ignore as its annoying
window.addEventListener('keydown', (e) => {
    if (e.code === 'KeyS' && (e.ctrlKey || e.metaKey)) {
        e.preventDefault();
    }
})

// model = createModel...
model.lastJsContent = ''
editor.onKeyDown(async (e) => {
  if(editor.getModel() !== model) return
  if (e.keyCode === monaco.KeyCode.KeyS && (e.ctrlKey || e.metaKey)) {
    e.preventDefault();
    saveFile(uri.toString(), model.getValue());
    const jsFile = await getCompiledJs(uri.toString())
    if(jsFile && model.lastJsContent !== jsFile.text) {
      console.log(jsFile)
      model.lastJsContent = jsFile.text;
    }
  }
});

File Tabs

HTML

html
<div class="codebar">
<div class="codefiles"><div class="codefiles-tabs"></div><div class="codefiles-actions"></div></div>
<div class="codebox"></div>
</div>

Javascript

javascript
const codebar = document.querySelector('.codebar');
const codefiles = document.querySelector('.codefiles-tabs');

function createCodefileTab(uri) {
  const tab = document.createElement('div')
  const filename = uri.split('/').pop()

  tab.className = 'codefile';
  tab.title = uri.replace('file:///', '')
  // tab.textContent = uri.split('/').pop();
  tab.innerHTML = `
<div class="codefile-name">
<div class="codefile-name1">${filename}</div></div>
<div class="codefile-button" title="Close" aria-label="Close ${filename}"></div>
`
  tab.dataset.path = uri
  tab.onclick = () => {
    if(tab.classList.contains('codefile-disabled')) return
    editor.setFileUri(uri.toString());
  };
  codefiles.appendChild(tab);
  return tab;
}

async function setEditorModel(uri){
  let model = monaco.editor.getModel(uri);
  if(!model) {
    console.error('Model not found for URI:', uri);
    return
  }

  [...codefiles.getElementsByClassName('codefile-selected')].forEach((elem)=>{
    elem.classList.remove('codefile-selected');
  })
  let tab = codefiles.querySelector(`div[data-path="${uri}"]`);
  if(!tab){
    tab = createCodefileTab(uri);
  }
  tab.classList.add('codefile-selected');

  editor.setModel(model);
}

async function setFileUri(uri){
  let model = monaco.editor.getModel(uri);
  if(!model) {
    console.error('Model not found for URI:', uri);
    return
  }

  [...codefiles.getElementsByClassName('codefile-selected')].forEach((elem)=>{
    elem.classList.remove('codefile-selected');
  })
  let tab = codefiles.querySelector(`div[data-path="${uri}"]`);
  if(!tab){
    tab = createCodefileTab(uri);
  }
  tab.classList.add('codefile-selected');

  editor.setModel(model);
}
editor.setFileUri = setFileUri
CSS
css
:root {
  --primary-color: #6d7a8c;
  --secondary-color: #ec630a;
  --background-color: #E7EFF8;
  --background-color-search: #dae1ea;
  --background-color-search-hover: #c3d4e7;
  --text-color: #1C2026;
  --text-color-accent: #2b313a;
  --text-color-hover: #2b313a;


  --tab-inactive-bg: var(--background-color-search);
  --foreground-alt: hsl(0, 0%, 41%);
  --tab-active-bg: #FFFFFE;
  --foreground-active: hsl(0, 1%, 17%);
  --hover-luminosity: hsl(220 60% 95% / 0.1);
}

@media (prefers-color-scheme: dark) {
  :root {
    --primary-color: #b6bfcb;
    --secondary-color: #ec630a;
    --background-color: #1C2026;
    --background-color-search: #3e4550;
    --background-color-search-hover: #555e6b;
    --text-color: #F6F7F9;
    --text-color-accent: #d2d3d5;
    --text-color-hover: #d2d3d5;

    --tab-inactive-bg: hsl(220 10% 16.5%);
    --foreground-alt: hsl(0 0% 62%);
    --tab-active-bg: #15181E;
    --foreground-active: hsl(0 0% 94%);
    --hover-luminosity: hsl(220 60% 95% / 0.1);
  }
}

.codebar {
  height: 100%;
  background: var(--background-color);
  color: var(--text-color);
  box-sizing: border-box;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  position: relative;
}

.codebar {
  width: max(320px, 40%);
  display: none;
  flex-direction: column;
  gap: 0;

}

.codefiles {
  --tab-height: 38px;
  background-color: var(--background-color);
  display: flex;
  flex-shrink: 0;
  height: var(--tab-height);
  overflow: hidden;
}

.codefiles-tabs {
  display: flex;
  height: calc(var(--tab-height) + 20px);
  overflow-x: auto;
  overflow-y: hidden;
  white-space: nowrap;
  width: 100%;
}
.codefiles-actions {
  align-items: center;
  display: flex;
  flex-shrink: 0;
}

.codefile{
  align-items: center;
  background-color: var(--tab-inactive-bg);
  color: var(--foreground-alt);
  cursor: pointer;
  display: inline-flex;
  flex-shrink: 0;
  font-size: 13px;
  font-weight: 400;
  height: var(--tab-height, 32px);
  line-height: var(--tab-height, 32px);
  min-width: 110px;
  padding: 0;
  padding-inline-end: 4px;
  padding-inline-start: 10px;
  position: relative;
}
.codefile-selected{
  background-color: var(--tab-active-bg);
  color: var(--foreground-active);
}
.codefile-disabled{
  opacity: 0.5;
  pointer-events: none;
}

.codefile-name{
  align-items: center;
  color: var(--text-color, currentColor);
  display: flex;
  flex-grow: 1;
  font-size: 13px;
  gap: 4px;
  user-select: none;
}
.codefile-name1{
  flex-grow: 1;
  line-height: normal;
  overflow: hidden;
  padding: 2px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.codefile-button{
  border-radius: 4px;
  color: inherit;
  display: block;
  flex: none;
  font-size: 12px;
  line-height: 1;
  opacity: 0;
  height: 20px;
  width: 20px;
  padding: 2px;
  box-sizing: border-box;
}
.codefile-button:hover{
  background-color: var(--hover-luminosity);
  color: var(--foreground-active);
}

.codefile-button::before{
  box-sizing: border-box;
  padding: 2px;
  content: '✕';
  display: flex;
  height: 16px;
  width: 16px;
  justify-content: center;
}

.codefile-unsaved > .codefile-button::before{
  content: '●';
}

.codefile:hover > .codefile-button,
.codefile-selected > .codefile-button,
.codefile-unsaved > .codefile-button {
  opacity: 1;
}

.codebox{
  width: 100%;
  height: 100%;
}

Patch script on loaded page

javascript
let exampleScript = scriptToPatch
function patchScript(){
  if(!model.lastJsContent) return
  const type = exampleScript.type
  const dataset = exampleScript.dataset
  const newScript = document.createElement('script');
  newScript.type = type;
  newScript.textContent = model.lastJsContent;
  for(const key of Object.keys(dataset)){
    newScript.dataset[key] = dataset[key];
  }
  exampleScript.remove()
  exampleScript = newScript;
  doc.head.appendChild(exampleScript);
  console.log('patched')
}

const res = setInterval(()=>{
  // todo clear interval
  // if(!exampleScript.parentElement) {
  //     clearInterval(res)
  //     console.warn('Example script not found in document, patching stopped');
  //     return
  // }
  if(model.lastJsContent && exampleScript.textContent !== model.lastJsContent) {
    patchScript()
  }
}, 2000)

TextMate grammar and highlighting

Using monaco-tm which is onigasm, monaco-textmate and monaco-editor-textmate in a single minified file, along with onigasm wasm and tm language grammar json files.

html
<script src="https://unpkg.com/[email protected]/dist/index.global.js"></script>
<script>
/* global monacoTM */
const langs = {
    ['source.css']: {
        lang: 'css',
        file: 'css.tmLanguage.json',
    },
    ['text.html.basic']: {
        lang: 'html',
        file: 'html.tmLanguage.json',
    },
    ['source.ts']: {
        lang: 'typescript',
        file: 'TypeScript.tmLanguage.json',
    },
    ['source.js']: {
        lang: 'javascript',
        file: 'JavaScript.tmLanguage.json',
    },
}
export async function setupTM (monaco, editor) {
    const base = 'https://unpkg.com/[email protected]/dist/'
    await monacoTM.loadWASM(base + monacoTM.onigasmWasm) // See https://www.npmjs.com/package/onigasm#light-it-up
    const registry = new monacoTM.Registry({
        getGrammarDefinition: async (scopeName) => {
            const file = langs[scopeName] ? langs[scopeName].file : null
            if(!file) {
                throw new Error(`No grammar definition found for scope: ${scopeName}`)
            }
            return {
                format: 'json',
                content: await (await fetch(base + 'grammars/' + file)).text(),
            }
        },
    })

    // map of monaco "language id's" to TextMate scopeNames
    const grammars = new Map()
    Object.entries(langs).forEach(([scope, {lang}]) => {
        grammars.set(lang, scope)
    })

    await monacoTM.wireTmGrammars(monaco, registry, grammars, editor)
}
</script>

Full VSCode environment (workbench)

Links

Made with ❤️ using the awesome vitepress