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
<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
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
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
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
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
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
- https://stackoverflow.com/questions/43058191/how-to-use-addextralib-in-monaco-with-an-external-type-definition
- https://github.com/Microsoft/monaco-editor/issues/1415
<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
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:
<script type="importmap">
{
"imports": {
"threepipe": "...",
"@threepipe/plugin-tweakpane": "..."
}
}
</script>
Handle save
// 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
<div class="codebar">
<div class="codefiles"><div class="codefiles-tabs"></div><div class="codefiles-actions"></div></div>
<div class="codebox"></div>
</div>
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
: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
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.
<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