Files
windmill/frontend/src/lib/components/TemplateEditor.svelte
T
Guilhem de76668c10 fix(frontend): align Monaco editor font size with text-xs (#9161)
* fix(frontend): align Monaco editor font size with text-xs across viewports

* fix(frontend): make placeholder lineHeight reactive to fontSize

* fix(frontend): align GraphQL schema viewer font size with text-xs

The read-only GraphQL schema viewer was the lone Monaco instance still
inheriting Monaco's 14px default. Wire it through editorFontSize like
the other editors so it stays in sync with text-xs across viewports.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:29:19 +00:00

723 lines
17 KiB
Svelte

<script module>
import '@codingame/monaco-vscode-standalone-typescript-language-features'
import {
javascriptDefaults,
getJavaScriptWorker
} from '@codingame/monaco-vscode-standalone-typescript-language-features'
</script>
<script lang="ts">
import { BROWSER } from 'esm-env'
import {
convertKind,
createDocumentationString,
displayPartsToString,
editorConfig,
registerWebviewPaste,
updateOptions
} from '$lib/editorUtils'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { createHash } from '$lib/editorLangUtils'
import libStdContent from '$lib/es6.d.ts.txt?raw'
import { editor as meditor, Uri as mUri, languages, Range, KeyMod, KeyCode } from 'monaco-editor'
import { createEventDispatcher, getContext, onDestroy, onMount, untrack } from 'svelte'
import type { AppViewerContext } from './apps/types'
import { writable } from 'svelte/store'
// import '@codingame/monaco-vscode-standalone-languages'
// import '@codingame/monaco-vscode-standalone-typescript-language-features'
import { initializeVscode, MONACO_Y_PADDING } from './vscode'
import EditorTheme from './EditorTheme.svelte'
import FakeMonacoPlaceHolder from './FakeMonacoPlaceHolder.svelte'
import { setMonacoJsonOptions } from './monacoLanguagesOptions'
import { inputBorderClass } from './text_input/TextInput.svelte'
import { twMerge } from 'tailwind-merge'
export const conf = {
wordPattern:
/(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
comments: {
lineComment: '//',
blockComment: ['/*', '*/'] as [string, string]
},
brackets: [
['{', '}'],
['[', ']'],
['(', ')']
] as [string, string][],
onEnterRules: [],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"', notIn: ['string'] },
{ open: "'", close: "'", notIn: ['string', 'comment'] },
{ open: '`', close: '`', notIn: ['string', 'comment'] }
],
folding: {
markers: {
start: new RegExp('^\\s*//\\s*#?region\\b'),
end: new RegExp('^\\s*//\\s*#?endregion\\b')
}
}
}
export const language = {
// Set defaultToken to invalid to see what you do not tokenize yet
defaultToken: 'invalid',
tokenPostfix: '.ts',
keywords: [
// Should match the keys of textToKeywordObj in
// https://github.com/microsoft/TypeScript/blob/master/src/compiler/scanner.ts
'abstract',
'any',
'as',
'asserts',
'bigint',
'boolean',
'break',
'case',
'catch',
'class',
'continue',
'const',
'constructor',
'debugger',
'declare',
'default',
'delete',
'do',
'else',
'enum',
'export',
'extends',
'false',
'finally',
'for',
'from',
'function',
'get',
'if',
'implements',
'import',
'in',
'infer',
'instanceof',
'interface',
'is',
'keyof',
'let',
'module',
'namespace',
'never',
'new',
'null',
'number',
'object',
'out',
'package',
'private',
'protected',
'public',
'override',
'readonly',
'require',
'global',
'return',
'set',
'static',
'string',
'super',
'switch',
'symbol',
'this',
'throw',
'true',
'try',
'type',
'typeof',
'undefined',
'unique',
'unknown',
'var',
'void',
'while',
'with',
'yield',
'async',
'await',
'of'
],
operators: [
'<=',
'>=',
'==',
'!=',
'===',
'!==',
'=>',
'+',
'-',
'**',
'*',
'/',
'%',
'++',
'--',
'<<',
'</',
'>>',
'>>>',
'&',
'|',
'^',
'!',
'~',
'&&',
'||',
'??',
'?',
':',
'=',
'+=',
'-=',
'*=',
'**=',
'/=',
'%=',
'<<=',
'>>=',
'>>>=',
'&=',
'|=',
'^=',
'@'
],
// we include these common regular expressions
symbols: /[=><!~?:&|+\-*\/\^%]+/,
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
digits: /\d+(_+\d+)*/,
octaldigits: /[0-7]+(_+[0-7]+)*/,
binarydigits: /[0-1]+(_+[0-1]+)*/,
hexdigits: /[[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/,
regexpctl: /[(){}\[\]\$\^|\-*+?\.]/,
regexpesc: /\\(?:[bBdDfnrstvwWn0\\\/]|@regexpctl|c[A-Z]|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4})/,
// The main tokenizer for our languages
tokenizer: {
root: [
[/\$\{/, { token: 'delimiter.bracket', next: '@bracketCounting' }],
[/[^\\`$]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid']
],
common: [
// identifiers and keywords
[
/[a-z_$][\w$]*/,
{
cases: {
'@keywords': 'keyword',
'@default': 'identifier'
}
}
],
[/[A-Z][\w\$]*/, 'type.identifier'], // to show class names nicely
// [/[A-Z][\w\$]*/, 'identifier'],
// whitespace
{ include: '@whitespace' },
// regular expression: ensure it is terminated before beginning (otherwise it is an opeator)
[
/\/(?=([^\\\/]|\\.)+\/([dgimsuy]*)(\s*)(\.|;|,|\)|\]|\}|$))/,
{ token: 'regexp', bracket: '@open', next: '@regexp' }
],
// delimiters and operators
[/[()\[\]]/, '@brackets'],
[/[<>](?!@symbols)/, '@brackets'],
[/!(?=([^=]|$))/, 'delimiter'],
[
/@symbols/,
{
cases: {
'@operators': 'delimiter',
'@default': ''
}
}
],
// numbers
[/(@digits)[eE]([\-+]?(@digits))?/, 'number.float'],
[/(@digits)\.(@digits)([eE][\-+]?(@digits))?/, 'number.float'],
[/0[xX](@hexdigits)n?/, 'number.hex'],
[/0[oO]?(@octaldigits)n?/, 'number.octal'],
[/0[bB](@binarydigits)n?/, 'number.binary'],
[/(@digits)n?/, 'number'],
// delimiter: after number because of .\d floats
[/[;,.]/, 'delimiter'],
// strings
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string
[/'([^'\\]|\\.)*$/, 'string.invalid'], // non-teminated string
[/"/, 'string', '@string_double'],
[/'/, 'string', '@string_single'],
[/`/, 'string', '@string_backtick']
],
whitespace: [
[/[ \t\r\n]+/, ''],
[/\/\*\*(?!\/)/, 'comment.doc', '@jsdoc'],
[/\/\*/, 'comment', '@comment'],
[/\/\/.*$/, 'comment']
],
comment: [
[/[^\/*]+/, 'comment'],
[/\*\//, 'comment', '@pop'],
[/[\/*]/, 'comment']
],
jsdoc: [
[/[^\/*]+/, 'comment.doc'],
[/\*\//, 'comment.doc', '@pop'],
[/[\/*]/, 'comment.doc']
],
// We match regular expression quite precisely
regexp: [
[
/(\{)(\d+(?:,\d*)?)(\})/,
['regexp.escape.control', 'regexp.escape.control', 'regexp.escape.control']
],
[
/(\[)(\^?)(?=(?:[^\]\\\/]|\\.)+)/,
['regexp.escape.control', { token: 'regexp.escape.control', next: '@regexrange' }]
],
[/(\()(\?:|\?=|\?!)/, ['regexp.escape.control', 'regexp.escape.control']],
[/[()]/, 'regexp.escape.control'],
[/@regexpctl/, 'regexp.escape.control'],
[/[^\\\/]/, 'regexp'],
[/@regexpesc/, 'regexp.escape'],
[/\\\./, 'regexp.invalid'],
[
/(\/)([dgimsuy]*)/,
[{ token: 'regexp', bracket: '@close', next: '@pop' }, 'keyword.other']
]
],
regexrange: [
[/-/, 'regexp.escape.control'],
[/\^/, 'regexp.invalid'],
[/@regexpesc/, 'regexp.escape'],
[/[^\]]/, 'regexp'],
[
/\]/,
{
token: 'regexp.escape.control',
next: '@pop',
bracket: '@close'
}
]
],
string_double: [
[/[^\\"]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/"/, 'string', '@pop']
],
string_single: [
[/[^\\']+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/'/, 'string', '@pop']
],
string_backtick: [
[/\$\{/, { token: 'delimiter.bracket', next: '@bracketCounting' }],
[/[^\\`$]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/`/, 'string', '@pop']
],
bracketCounting: [
[/\{/, 'delimiter.bracket', '@bracketCounting'],
[/\}/, 'delimiter.bracket', '@pop'],
{ include: 'common' }
]
}
}
let divEl: HTMLDivElement | null = $state(null)
let editor: meditor.IStandaloneCodeEditor | undefined = $state(undefined)
let pasteCleanup: (() => void) | undefined = undefined
let model: meditor.ITextModel
const { componentControl, selectedComponent } = getContext<AppViewerContext>(
'AppViewerContext'
) || { componentControl: writable({}), selectedComponent: writable([]) }
if ($selectedComponent) {
$componentControl[$selectedComponent[0]] = {
...$componentControl[$selectedComponent[0]],
setCode: (value: string) => {
setCode(value)
}
}
}
interface Props {
code?: string
hash?: string
automaticLayout?: boolean
extraLib?: string
autoHeight?: boolean
fixedOverflowWidgets?: boolean
fontSize?: number
loadAsync?: boolean
class?: string | undefined
}
let {
code = $bindable(),
hash = createHash(),
automaticLayout = true,
extraLib = '',
autoHeight = true,
fixedOverflowWidgets = true,
fontSize,
loadAsync = false,
class: clazz = ''
}: Props = $props()
let effectiveFontSize = $derived(fontSize ?? editorFontSize.regular)
let yPadding = MONACO_Y_PADDING
if (typeof code != 'string') {
code = ''
}
const lang = 'template'
const dispatch = createEventDispatcher()
const uri = `file:///${untrack(() => hash)}.ts`
export function insertAtCursor(code: string): void {
if (editor) {
editor.trigger('keyboard', 'type', { text: code })
}
}
export function setCode(ncode: string): void {
if (code != ncode) {
code = ncode
}
editor?.setValue(ncode)
}
let valueAfterDispose: string | undefined = undefined
export function getCode(): string {
if (valueAfterDispose != undefined) {
return valueAfterDispose
}
return editor?.getValue() ?? ''
}
let cip
let extraModel
// let widgets: HTMLElement | undefined = document.getElementById('monaco-widgets-root') ?? undefined
let initialized = $state(false)
let jsLoader: number | undefined = undefined
let timeoutModel: number | undefined = undefined
async function loadMonaco() {
setMonacoJsonOptions()
await initializeVscode('templateEditor')
initialized = true
languages.register({ id: 'template' })
// Register a tokens provider for the language
languages.registerTokensProviderFactory('template', {
create: () => language as languages.IMonarchLanguage
})
languages.setLanguageConfiguration('template', conf)
model = meditor.createModel(code ?? '', lang, mUri.parse(uri))
model.updateOptions(updateOptions)
try {
editor = meditor.create(divEl as HTMLDivElement, {
...editorConfig(code ?? '', lang, automaticLayout, fixedOverflowWidgets, false),
model,
// overflowWidgetsDomNode: widgets,
// lineNumbers: 'on',
lineDecorationsWidth: 0,
lineNumbersMinChars: 2,
fontSize: effectiveFontSize,
suggestOnTriggerCharacters: true,
renderLineHighlight: 'none',
lineNumbers: 'off',
...(yPadding !== undefined ? { padding: { bottom: yPadding, top: yPadding } } : {})
})
} catch (e) {
console.error('Error loading monaco:', e)
return
}
// In VSCode webview (iframe), clipboard operations need special handling
// because the webview has restricted clipboard API access
if (window.parent !== window) {
editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyC, function () {
document.execCommand('copy')
})
editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyX, function () {
document.execCommand('cut')
})
// Paste is scoped to this editor's container instead of a global
// Ctrl+V keybinding, which would leak across editor instances.
pasteCleanup?.()
pasteCleanup = registerWebviewPaste(divEl, () => editor)
}
editor.onDidFocusEditorText(() => {
dispatch('focus')
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () {})
editor?.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit7, function () {})
})
function updateCode() {
const ncode = getCode()
if (code == ncode) {
return
}
code = ncode
dispatch('change', { code: ncode })
}
editor.onDidChangeModelContent((event) => {
timeoutModel && clearTimeout(timeoutModel)
timeoutModel = setTimeout(() => {
updateCode()
}, 200)
})
extraModel = meditor.createModel('`' + model.getValue() + '`', 'javascript')
if (autoHeight) {
const updateHeight = () => {
const contentHeight = Math.min(1000, editor?.getContentHeight() ?? 0)
if (divEl) {
divEl.style.height = `${contentHeight}px`
}
}
editor.onDidContentSizeChange(updateHeight)
updateHeight()
}
editor.onDidFocusEditorText(() => {
dispatch('focus')
isFocus = true
})
editor.onDidBlurEditorText(() => {
dispatch('blur')
isFocus = false
updateCode()
})
jsLoader = setTimeout(async () => {
jsLoader = undefined
try {
const worker = await getJavaScriptWorker()
const client = await worker(extraModel.uri)
cip = languages.registerCompletionItemProvider('template', {
triggerCharacters: ['.'],
provideCompletionItems: async (model, position) => {
extraModel.setValue('`' + model.getValue() + '`')
const offset = model.getOffsetAt(position) + 1
const info = await client.getCompletionsAtPosition(extraModel.uri.toString(), offset)
if (!info) {
return { suggestions: [] }
}
const wordInfo = model.getWordUntilPosition(position)
const wordRange = new Range(
position.lineNumber,
wordInfo.startColumn,
position.lineNumber,
wordInfo.endColumn
)
const suggestions = info.entries
.filter((x) => x.kind != 'keyword' && x.kind != 'var')
.map((entry) => {
let range = wordRange
if (entry.replacementSpan) {
const p1 = model.getPositionAt(entry.replacementSpan.start)
const p2 = model.getPositionAt(
entry.replacementSpan.start + entry.replacementSpan.length
)
range = new Range(p1.lineNumber, p1.column, p2.lineNumber, p2.column)
}
const tags: languages.CompletionItemTag[] = []
if (entry.kindModifiers?.indexOf('deprecated') !== -1) {
tags.push(languages.CompletionItemTag.Deprecated)
}
return {
uri: model.uri,
position: position,
offset: offset,
range: range,
label: entry.name,
insertText: entry.name,
sortText: entry.sortText,
kind: convertKind(entry.kind),
tags
}
})
return { suggestions }
},
resolveCompletionItem: async (item: languages.CompletionItem, token: any) => {
extraModel.setValue('`' + model.getValue() + '`')
const myItem = item as any
const position = myItem.position
const offset = myItem.offset
const details = await client.getCompletionEntryDetails(
extraModel.uri.toString(),
offset,
myItem.label
)
if (!details) {
return myItem
}
return {
uri: model.uri,
position: position,
label: details.name,
kind: convertKind(details.kind),
detail: displayPartsToString(details.displayParts),
documentation: {
value: createDocumentationString(details)
}
} as any
}
})
} catch (e) {
console.error('Error loading javascipt worker:', e)
}
}, 300)
}
export function focus() {
editor?.focus()
}
let isFocus = $state(false)
let mounted = $state(false)
let loadTimeout: number | undefined = undefined
onMount(async () => {
try {
if (BROWSER) {
if (loadAsync) {
loadTimeout = setTimeout(async () => {
await loadMonaco()
mounted = true
}, 0)
} else {
await loadMonaco()
mounted = true
}
}
} catch (e) {
console.error('Error loading monaco:', e)
}
})
function loadExtraLib() {
const stdLib = { content: libStdContent, filePath: 'es6.d.ts' }
const libs = [stdLib]
if (extraLib != '') {
libs.push({
content: extraLib,
filePath: 'windmill.d.ts'
})
}
javascriptDefaults.setExtraLibs(libs)
}
onDestroy(() => {
try {
valueAfterDispose = getCode()
pasteCleanup?.()
jsLoader && clearTimeout(jsLoader)
timeoutModel && clearTimeout(timeoutModel)
loadTimeout && clearTimeout(loadTimeout)
model && model.dispose()
editor && editor.dispose()
cip && cip.dispose()
extraModel && extraModel.dispose()
} catch (err) {}
})
$effect(() => {
mounted && extraLib && initialized && untrack(() => loadExtraLib())
})
$effect(() => {
const next = effectiveFontSize
if (editor) {
editor.updateOptions({ fontSize: next })
}
})
</script>
<EditorTheme />
<div
class={twMerge(inputBorderClass({ forceFocus: isFocus }), 'rounded-md overflow-auto pl-2', clazz)}
>
{#if !editor}
<FakeMonacoPlaceHolder autoheight showNumbers={false} {code} fontSize={effectiveFontSize} />
{/if}
<div
bind:this={divEl}
style="height: 18px;"
class="template nonmain-editor rounded-md overflow-clip {!editor ? 'hidden' : ''}"
></div>
</div>
<style>
:global(.template .mtk20) {
color: black !important;
}
</style>