Skip to content

Instantly share code, notes, and snippets.

@westc
Last active November 5, 2024 03:53
Show Gist options
  • Save westc/80bac6194dfbecd1c96bb8bda80ddb4d to your computer and use it in GitHub Desktop.
Save westc/80bac6194dfbecd1c96bb8bda80ddb4d to your computer and use it in GitHub Desktop.
Get a <code-editor> component in Vue3 that uses Monaco.
/**
* @param {Record<"codeEditor"|"diffEditor",boolean|string>} defsToInclude
* @returns {Record<string,any>}
*/
function getMonacoEditorCompDefs(defsToInclude) {
/**
* @param {(script?: HTMLScriptElement) => T} getter
* Function that will be called to get the result of a script running. This
* function will initially be called without any arguments to determine if
* the desired results already exist but if not the script tag will be added
* and then this will be run again after the script has been loaded.
* @param {string} scriptUrl
* @returns {Promise<T>}
* @template T
*/
async function loadScriptValue(getter, scriptUrl) {
let value;
try {
value = await getter();
} catch(e){}
return value !== undefined
? value
: new Promise((resolve, reject) => {
document.head.appendChild(Object.assign(document.createElement('script'), {
src: scriptUrl,
onload() {
const intervalID = setInterval(async () => {
value = await getter(this);
if (value !== undefined) {
resolve(value);
clearInterval(intervalID);
}
}, 50);
},
onerror(evt) {
reject(evt);
clearInterval(intervalID);
}
}));
});
}
function waitUntil(tester, interval=100) {
const start = +new Date();
return new Promise(async resolve => {
while (true) {
const value = tester(start, interval);
if (value) return resolve(value);
await new Promise((resolveInner) => setTimeout(resolveInner, interval));
}
});
}
function parseRulers(rulers) {
if (rulers == null) return rulers;
if ('string' === typeof rulers) {
rulers = rulers.split(/\s*,\s*/);
}
return rulers.reduce((rulers, ruler) => {
const intRuler = parseInt(+ruler, 10);
if (ruler == intRuler) {
rulers.push(intRuler);
}
return rulers;
}, []);
}
async function ensureMonaco() {
// Add monaco editor CSS and JS if the CSS isn't already found.
const jsAttrKey = 'code-editor-vue3-comp';
if (!document.head.querySelector(`script[${jsAttrKey}]`)) {
const script = document.createElement('script');
script.src = 'https://unpkg.com/monaco-editor@0/min/vs/loader.js';
script.setAttribute(jsAttrKey, 1);
document.head.appendChild(script);
// Load the necessary require function to then load monaco.
await waitUntil(() => window.require?.config);
require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0/min/vs' } });
await new Promise(resolve => require(['vs/editor/editor.main'], resolve));
}
else if (!window.monaco) {
await waitUntil(() => window.monaco);
}
}
const codeEditorDef = {
template: `
<div ref="editor" :style="style"></div>
`,
watch: {
modelValue(newValue, oldValue) {
if (this.skipModelValueUpdate) {
this.skipModelValueUpdate = false;
}
else {
this.$.editor.setValue(newValue);
}
},
readonly(newValue) {
this.$.editor.updateOptions({readOnly: newValue});
},
wrap(newValue) {
this.$.editor.updateOptions({wordWrap: newValue ? 'on' : 'off'});
},
rulers(newValue) {
this.$.editor.updateOptions({rulers: parseRulers(newValue)});
}
},
data() {
return {
skipModelValueUpdate: false
};
},
methods: {
emitModelValueUpdate(newValue) {
this.skipModelValueUpdate = true;
this.$emit('update:modelValue', newValue);
}
},
props: {
modelValue: String,
mode: {
type: String,
default: 'javascript'
},
height: {
type: [Number,String],
default: '200px'
},
readonly: {
type: Boolean,
default: false,
},
wrap: {
type: Boolean,
default: true,
},
rulers: {
type: [Array,String],
validator(value) {
return parseRulers(value)?.length > 0;
},
default: "80",
}
},
computed: {
style() {
return {
height: `${this.height}`.replace(/^\d+$/, '$&px')
};
}
},
async mounted() {
// Wait until monaco is actually available.
await ensureMonaco();
const editor = monaco.editor.create(this.$refs.editor, {
value: this.modelValue,
language: this.mode,
readOnly: this.readonly,
wordWrap: this.wrap ? 'on' : 'off',
rulers: parseRulers(this.rulers),
automaticLayout: true,
});
editor.onDidChangeModelContent(evt => {
// Only emit model value update when not flush. `!evt.isFlush`
// seems to indicate that the value was updated from within the
// editor.
if (!evt.isFlush) this.emitModelValueUpdate(editor.getValue());
});
this.$.editor = editor;
},
};
const diffEditorDef = {
template: `
<div ref="editor" :style="style"></div>
`,
watch: {
leftValue(newValue, oldValue) {
if (this.skipLeftValueUpdate) {
this.skipLeftValueUpdate = false;
}
else {
this.$.leftModel.setValue(newValue);
}
},
rightValue(newValue, oldValue) {
if (this.skipRightValueUpdate) {
this.skipRightValueUpdate = false;
}
else {
this.$.rightModel.setValue(newValue);
}
},
leftEditable(newValue) {
this.$.editor.updateOptions({originalEditable: newValue});
},
rightEditable(newValue) {
this.$.editor.updateOptions({readOnly: !newValue});
},
wrap(newValue) {
this.$.editor.updateOptions({wordWrap: newValue ? 'on' : 'off'});
},
rulers(newValue) {
this.$.editor.updateOptions({rulers: parseRulers(newValue)});
}
},
data() {
return {
skipLeftValueUpdate: false,
skipRightValueUpdate: false,
};
},
methods: {
emitLeftValueUpdate(newValue) {
this.skipLeftValueUpdate = true;
this.$emit('update:leftValue', newValue);
},
emitRightValueUpdate(newValue) {
this.skipRightValueUpdate = true;
this.$emit('update:rightValue', newValue);
},
},
props: {
leftValue: String,
rightValue: String,
mode: {
type: String,
default: 'javascript'
},
height: {
type: [Number,String],
default: '200px'
},
leftEditable: {
type: Boolean,
default: false,
},
rightEditable: {
type: Boolean,
default: false,
},
wrap: {
type: Boolean,
default: true,
},
rulers: {
type: [Array,String],
validator(value) {
return parseRulers(value)?.length > 0;
},
default: "80",
}
},
computed: {
style() {
return {
height: `${this.height}`.replace(/^\d+$/, '$&px')
};
}
},
async mounted() {
// Wait until monaco is actually available.
await ensureMonaco();
this.$.leftModel = monaco.editor.createModel(this.leftValue, this.mode);
this.$.rightModel = monaco.editor.createModel(this.rightValue, this.mode);
const editor = monaco.editor.createDiffEditor(this.$refs.editor, {
language: this.mode,
readOnly: !this.rightEditable,
originalEditable: this.leftEditable,
wordWrap: this.wrap ? 'on' : 'off',
rulers: parseRulers(this.rulers),
automaticLayout: true,
});
editor.setModel({
original: this.$.leftModel,
modified: this.$.rightModel,
});
editor.getOriginalEditor().onDidChangeModelContent(evt => {
if (!evt.isFlush) {
this.emitLeftValueUpdate(this.$.leftModel.getValue());
}
});
editor.getModifiedEditor().onDidChangeModelContent(evt => {
if (!evt.isFlush) {
this.emitRightValueUpdate(this.$.rightModel.getValue());
}
});
this.$.editor = editor;
},
};
const defs = {};
const {codeEditor, diffEditor} = defsToInclude;
if (codeEditor) {
defs[codeEditor === true ? 'codeEditor' : codeEditor] = codeEditorDef;
}
if (diffEditor) {
defs[diffEditor === true ? 'diffEditor' : diffEditor] = diffEditorDef;
}
return defs;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Monaco Editors</title>
<script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>
<script src="getMonacoEditorCompDef.js"></script>
<script src="page.js"></script>
</head>
<body>
<div id="vueApp">
{{ code.length }}
<code-editor v-model="code"
mode="python"
height="250"
rulers="80,120"
:wrap="true"
:readonly="false">
</code-editor>
<diff-editor v-model:left-value="code"
v-model:right-value="rightCode"
mode="python"
height="250"
rulers="80,120"
:wrap="true"
:left-editable="true"
:right-editable="true">
</diff-editor>
</div>
</body>
</html>
const vueApp = window.vueApp = Vue.createApp({
data() {
return {
code: 'from chc_py_transform import transform',
leftCode: 'from chc_py_transform import transform',
rightCode: 'from chc_py_transform import transform',
};
},
components: {
...getMonacoEditorCompDefs({codeEditor: true, diffEditor: true})
}
}).mount('#vueApp');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment