feat(frontend): app editor code input component (monaco) (#5566)

* feat(frontend): app editor code input component (monaco)

* only import when needed + svelte 5

* simple editor -> svelte5

* removing unneccessary rename

* fix vimMode

* nit fixes

* fix height

* rm global

* add html support

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Alexander Petric
2025-04-11 13:42:22 -04:00
committed by GitHub
parent 09d9f692ff
commit f90a4c4dbb
10 changed files with 344 additions and 42 deletions
+10
View File
@@ -12,6 +12,7 @@
"@aws-crypto/sha256-js": "^4.0.0",
"@codingame/monaco-vscode-configuration-service-override": "~11.1.2",
"@codingame/monaco-vscode-standalone-css-language-features": "~11.1.2",
"@codingame/monaco-vscode-standalone-html-language-features": "^11.1.2",
"@codingame/monaco-vscode-standalone-json-language-features": "~11.1.2",
"@codingame/monaco-vscode-standalone-languages": "~11.1.2",
"@codingame/monaco-vscode-standalone-typescript-language-features": "~11.1.2",
@@ -567,6 +568,15 @@
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@11.1.2"
}
},
"node_modules/@codingame/monaco-vscode-standalone-html-language-features": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-standalone-html-language-features/-/monaco-vscode-standalone-html-language-features-11.1.2.tgz",
"integrity": "sha512-PHRiZRH9ENI7hBJ7b+VSk78TCfQNqGVaS/fbr3jHcS98ohHdlfTRry/Kl1CT7aRIZj7yV9iNWX57hN+lzPp2vg==",
"license": "MIT",
"dependencies": {
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@11.1.2"
}
},
"node_modules/@codingame/monaco-vscode-standalone-json-language-features": {
"version": "11.1.2",
"license": "MIT",
+1
View File
@@ -85,6 +85,7 @@
"@aws-crypto/sha256-js": "^4.0.0",
"@codingame/monaco-vscode-configuration-service-override": "~11.1.2",
"@codingame/monaco-vscode-standalone-css-language-features": "~11.1.2",
"@codingame/monaco-vscode-standalone-html-language-features": "^11.1.2",
"@codingame/monaco-vscode-standalone-json-language-features": "~11.1.2",
"@codingame/monaco-vscode-standalone-languages": "~11.1.2",
"@codingame/monaco-vscode-standalone-typescript-language-features": "~11.1.2",
+145 -41
View File
@@ -1,12 +1,12 @@
<script context="module">
let cssClassesLoaded = writable(false)
let tailwindClassesLoaded = writable(false)
<script module>
let cssClassesLoaded = $state(false)
let tailwindClassesLoaded = $state(false)
import '@codingame/monaco-vscode-standalone-languages'
import '@codingame/monaco-vscode-standalone-json-language-features'
import '@codingame/monaco-vscode-standalone-css-language-features'
import '@codingame/monaco-vscode-standalone-typescript-language-features'
import '@codingame/monaco-vscode-standalone-html-language-features'
languages.typescript.javascriptDefaults.setCompilerOptions({
target: languages.typescript.ScriptTarget.Latest,
allowNonTsExtensions: true,
@@ -62,7 +62,6 @@
import domContent from '$lib/dom.d.ts.txt?raw'
import { initializeVscode } from './vscode'
import EditorTheme from './EditorTheme.svelte'
import { writable } from 'svelte/store'
import { vimMode } from '$lib/stores'
import { initVim } from './monaco_keybindings'
import { buildWorkerDefinition } from '$lib/monaco_workers/build_workers'
@@ -70,26 +69,59 @@
// import type { IStandaloneCodeEditor } from 'vscode/vscode/vs/editor/standalone/browser/standaloneCodeEditor'
let divEl: HTMLDivElement | null = null
let editor: meditor.IStandaloneCodeEditor
let editor = $state<meditor.IStandaloneCodeEditor | null>(null)
let model: meditor.ITextModel
export let lang: string
export let code: string = ''
export let hash: string = createHash()
export let cmdEnterAction: (() => void) | undefined = undefined
export let formatAction: (() => void) | undefined = undefined
export let automaticLayout = true
export let extraLib: string = ''
export let placeholder: string = ''
let statusDiv = $state<Element | null>(null)
let width = $state(0)
let initialized = $state(false)
let suggestion = $state('')
let placeholderVisible = $state(false)
let mounted = $state(false)
export let shouldBindKey: boolean = true
export let autoHeight = false
export let fixedOverflowWidgets = true
export let small = false
export let domLib = false
export let autofocus = false
export let allowVim = false
export let tailwindClasses: string[] = []
let {
lang,
code = $bindable(),
hash = createHash(),
cmdEnterAction,
formatAction,
automaticLayout = true,
extraLib = '',
placeholder = '',
disableSuggestions = false,
disableLinting = false,
hideLineNumbers = false,
shouldBindKey = true,
autoHeight = false,
fixedOverflowWidgets = true,
small = false,
domLib = false,
autofocus = false,
allowVim = false,
tailwindClasses = [],
class: className = ''
} = $props<{
lang: string
code?: string
hash?: string
cmdEnterAction?: () => void
formatAction?: () => void
automaticLayout?: boolean
extraLib?: string
placeholder?: string
disableSuggestions?: boolean
disableLinting?: boolean
hideLineNumbers?: boolean
shouldBindKey?: boolean
autoHeight?: boolean
fixedOverflowWidgets?: boolean
small?: boolean
domLib?: boolean
autofocus?: boolean
allowVim?: boolean
tailwindClasses?: string[]
class?: string
}>()
const dispatch = createEventDispatcher()
@@ -112,7 +144,6 @@
editor?.setValue(ncode)
}
let placeholderVisible = false
function updatePlaceholderVisibility(value: string) {
if (!value) {
placeholderVisible = true
@@ -165,22 +196,29 @@
divEl?.classList.add('hidden')
}
let suggestion = ''
export function setSuggestion(value: string): void {
suggestion = value
}
let width = 0
let initialized = false
let disableTabCond: meditor.IContextKey<boolean> | undefined
$: disableTabCond?.set(!code && !!suggestion)
let statusDiv: Element | null = null
$effect(() => {
disableTabCond?.set(!code && !!suggestion)
})
let vimDisposable: IDisposable | undefined = undefined
$: allowVim && editor && $vimMode && statusDiv && onVimMode()
$: !$vimMode && vimDisposable && onVimDisable()
$effect(() => {
if (allowVim && editor !== null && $vimMode && statusDiv) {
onVimMode()
}
})
$effect(() => {
if (!$vimMode && vimDisposable) {
onVimDisable()
}
})
function onVimDisable() {
vimDisposable?.dispose()
@@ -194,6 +232,53 @@
}
}
function updateModelAndOptions() {
const model = editor?.getModel()
if (model) {
// Switch language if it changed
if (model.getLanguageId() !== lang) {
const currentCode = model.getValue()
const uri = `file:///${hash}.${langToExt(lang)}`
const oldModel = model
const newModel = meditor.createModel(currentCode, lang, mUri.parse(uri))
editor?.setModel(newModel)
oldModel.dispose()
}
// Update editor options for suggestions, validation decorations, and line numbers
editor?.updateOptions({
quickSuggestions: disableSuggestions
? { other: false, comments: false, strings: false }
: { other: true, comments: true, strings: true },
suggestOnTriggerCharacters: !disableSuggestions,
wordBasedSuggestions: disableSuggestions ? 'off' : 'matchingDocuments',
parameterHints: { enabled: !disableSuggestions },
suggest: {
showIcons: !disableSuggestions,
showSnippets: !disableSuggestions,
showKeywords: !disableSuggestions,
showWords: !disableSuggestions,
snippetsPreventQuickSuggestions: disableSuggestions
},
lineNumbers: hideLineNumbers ? 'off' : 'on',
lineDecorationsWidth: hideLineNumbers ? 0 : 6,
lineNumbersMinChars: hideLineNumbers ? 0 : 2,
// Hide validation squiggles and decorations
renderValidationDecorations: disableLinting ? 'off' : 'on',
// Hide the validation margin indicators
hideCursorInOverviewRuler: disableLinting,
overviewRulerBorder: !disableLinting,
overviewRulerLanes: disableLinting ? 0 : 3
})
}
}
$effect(() => {
if (editor !== null && (lang || disableLinting || disableSuggestions || hideLineNumbers)) {
updateModelAndOptions()
}
})
async function loadMonaco() {
await initializeVscode()
initialized = true
@@ -242,8 +327,20 @@
model,
lineDecorationsWidth: 6,
lineNumbersMinChars: 2,
// overflowWidgetsDomNode: widgets,
fontSize: small ? 12 : 14
fontSize: small ? 12 : 14,
quickSuggestions: disableSuggestions
? { other: false, comments: false, strings: false }
: { other: true, comments: true, strings: true },
suggestOnTriggerCharacters: !disableSuggestions,
wordBasedSuggestions: disableSuggestions ? 'off' : 'matchingDocuments',
parameterHints: { enabled: !disableSuggestions },
suggest: {
showIcons: !disableSuggestions,
showSnippets: !disableSuggestions,
showKeywords: !disableSuggestions,
showWords: !disableSuggestions,
snippetsPreventQuickSuggestions: disableSuggestions
}
})
let timeoutModel: NodeJS.Timeout | undefined = undefined
@@ -257,6 +354,7 @@
})
editor.onDidFocusEditorText(() => {
if (!editor) return
dispatch('focus')
loadExtraLib()
@@ -276,6 +374,7 @@
if (autoHeight) {
const updateHeight = () => {
if (!editor) return
const contentHeight = Math.min(1000, editor.getContentHeight())
if (divEl) {
divEl.style.height = `${contentHeight}px`
@@ -290,6 +389,7 @@
}
editor.onDidFocusEditorText(() => {
if (!editor) return
editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () {
code = getCode()
shouldBindKey && format && format()
@@ -307,19 +407,20 @@
code = getCode()
})
if (lang === 'css' && !$cssClassesLoaded) {
$cssClassesLoaded = true
if (lang === 'css' && !cssClassesLoaded) {
cssClassesLoaded = true
addCSSClassCompletions()
}
if (lang === 'tailwindcss' && !$tailwindClassesLoaded) {
if (lang === 'tailwindcss' && !tailwindClassesLoaded) {
languages.register({ id: 'tailwindcss' })
$tailwindClassesLoaded = true
tailwindClassesLoaded = true
addTailwindClassCompletions()
}
if (placeholder) {
editor.onDidChangeModelContent(() => {
if (!editor) return
const value = editor.getValue()
updatePlaceholderVisibility(value)
})
@@ -408,7 +509,6 @@
}
}
let mounted = false
onMount(async () => {
if (BROWSER) {
mounted = true
@@ -421,7 +521,11 @@
}
})
$: mounted && extraLib && initialized && loadExtraLib()
$effect(() => {
if (mounted && extraLib && initialized) {
loadExtraLib()
}
})
onDestroy(() => {
try {
@@ -454,7 +558,7 @@
{/if}
<div
bind:this={divEl}
class="relative {$$props.class ?? ''} editor simple-editor {!allowVim ? 'nonmain-editor' : ''}"
class="relative {className} editor simple-editor {!allowVim ? 'nonmain-editor' : ''}"
bind:clientWidth={width}
>
{#if placeholder}
@@ -468,7 +572,7 @@
</div>
{/if}
</div>
{#if allowVim && $vimMode}
{#if allowVim && vimMode}
<div class="fixed bottom-0 z-30" bind:this={statusDiv}></div>
{/if}
@@ -0,0 +1,104 @@
<script lang="ts">
import { getContext } from 'svelte'
import { initConfig, initOutput } from '../../editor/appUtils'
import { type AppViewerContext, type RichConfigurations } from '../../types'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import InputValue from '../helpers/InputValue.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
let { id, configuration, render } = $props<{
id: string
configuration: RichConfigurations
render: boolean
}>()
const { componentControl, worldStore, selectedComponent, connectingInput, mode } =
getContext<AppViewerContext>('AppViewerContext')
let resolvedConfig = $state(initConfig(
components['codeinputcomponent'].initialData.configuration,
configuration
))
let code = $state<string | undefined>(undefined)
let placeholder = $state<string | undefined>(undefined)
let defaultValue = $state<string | undefined>(undefined)
let editorInstance = $state<any>(null)
let lastDefaultValue = $state<string | undefined>(undefined)
let lang = $derived(resolvedConfig?.lang ?? 'javascript')
let outputs = $state(initOutput($worldStore, id, {
result: ''
}))
$effect(() => {
if (defaultValue !== lastDefaultValue) {
code = defaultValue
editorInstance?.setCode(defaultValue)
lastDefaultValue = defaultValue
}
if (code !== undefined) {
outputs?.result.set(code)
}
})
$componentControl[id] = {
...$componentControl[id],
setValue(value: string) {
code = value
editorInstance?.setCode(value)
}
}
</script>
<InputValue key="placeholder" {id} input={configuration.placeholder} bind:value={placeholder} />
<InputValue key="value" {id} input={configuration.defaultValue} bind:value={defaultValue} />
<InitializeComponent {id} />
{#each Object.keys(components['codeinputcomponent'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
{key}
bind:resolvedConfig={resolvedConfig[key]}
configuration={configuration[key]}
/>
{/each}
{#if render}
<div
class="h-full flex-col flex max-h-full overflow-scroll editor-wrapper rounded-md border border-gray-300 dark:border-gray-500 wm-code-editor"
onpointerdown={(e) => {
e.stopPropagation()
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
}}
>
{#await import('$lib/components/SimpleEditor.svelte')}
<div class="flex items-center justify-center h-full">
<div class="text-gray-500 dark:text-gray-400">Loading editor...</div>
</div>
{:then Module}
<Module.default
bind:this={editorInstance}
bind:code
{lang}
class="h-full"
automaticLayout={true}
autoHeight={false}
{placeholder}
disableSuggestions={resolvedConfig?.disableSuggestions ?? false}
disableLinting={resolvedConfig?.disableLinting ?? false}
hideLineNumbers={resolvedConfig?.hideLineNumbers ?? false}
fixedOverflowWidgets={$mode == 'dnd' ? false : true}
/>
{/await}
</div>
{/if}
<style lang="postcss">
:global(.suggest-widget) {
position: fixed !important;
}
</style>
@@ -44,6 +44,7 @@
import AppTable from '../../components/display/table/AppTable.svelte'
import AppAggridTable from '../../components/display/table/AppAggridTable.svelte'
import AppText from '../../components/display/AppText.svelte'
import AppCodeInputComponent from '../../components/inputs/AppCodeInputComponent.svelte'
import AppButton from '../../components/buttons/AppButton.svelte'
import AppForm from '../../components/buttons/AppForm.svelte'
import AppFormButton from '../../components/buttons/AppFormButton.svelte'
@@ -295,6 +296,8 @@
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'codeinputcomponent'}
<AppCodeInputComponent id={component.id} configuration={component.configuration} {render} />
{:else if component.type === 'buttoncomponent'}
<AppButton
id={component.id}
@@ -53,7 +53,8 @@ import {
PanelTop,
RefreshCw,
ListCollapse,
GalleryThumbnails
GalleryThumbnails,
Code
} from 'lucide-svelte'
import type {
Aligned,
@@ -93,6 +94,7 @@ export type CustomComponentConfig = {
export type TextComponent = BaseComponent<'textcomponent'>
export type TextInputComponent = BaseComponent<'textinputcomponent'>
export type QuillComponent = BaseComponent<'quillcomponent'>
export type CodeInputComponent = BaseComponent<'codeinputcomponent'>
export type TextareaInputComponent = BaseComponent<'textareainputcomponent'>
export type PasswordInputComponent = BaseComponent<'passwordinputcomponent'>
export type EmailInputComponent = BaseComponent<'emailinputcomponent'>
@@ -304,6 +306,7 @@ export type TypedComponent =
| JobIdFlowStatusComponent
| TextInputComponent
| QuillComponent
| CodeInputComponent
| TextareaInputComponent
| PasswordInputComponent
| EmailInputComponent
@@ -1204,6 +1207,65 @@ export const components = {
}
}
},
codeinputcomponent: {
name: 'Code Input',
icon: Code,
dims: '2:1-4:4' as AppComponentDimensions,
documentationLink: `${documentationBaseUrl}/code`,
customCss: {
text: { class: '', style: '' },
container: { class: '', style: '' }
},
initialData: {
componentInput: undefined,
configuration: {
placeholder: {
type: 'static',
value: 'Type...',
fieldType: 'text'
},
defaultValue: {
type: 'static',
value: undefined,
fieldType: 'text'
},
lang: {
type: 'static',
fieldType: 'select',
value: 'javascript',
selectOptions: [
'javascript',
'typescript',
'python',
'sql',
'json',
'html',
'css',
'markdown',
'yaml'
]
},
disableSuggestions: {
type: 'static',
fieldType: 'boolean',
value: false,
tooltip: 'Disable code completion suggestions'
},
disableLinting: {
type: 'static',
fieldType: 'boolean',
value: false,
tooltip: 'Disable code validation/linting (keeps only syntax highlighting)'
},
hideLineNumbers: {
type: 'static',
fieldType: 'boolean',
value: false,
tooltip: 'Hide line numbers in the editor'
}
}
}
},
buttoncomponent: {
name: 'Button',
icon: Inspect,
@@ -37,6 +37,7 @@ const inputs: ComponentSet = {
components: [
'schemaformcomponent',
'textinputcomponent',
'codeinputcomponent',
'textareainputcomponent',
'quillcomponent',
'passwordinputcomponent',
@@ -121,6 +121,7 @@ export function getComponentControl(type: keyof typeof components): Array<Compon
case 'dateslidercomponent':
case 'quillcomponent':
case 'textcomponent':
case 'codeinputcomponent':
case 'textareainputcomponent':
return [setValue]
case 'formcomponent':
@@ -568,6 +568,19 @@ export const customisationByComponent: Customisation[] = [
],
variables: []
},
{
components: ['codeinputcomponent'],
selectors: [
{ selector: '.wm-code-editor', comment: 'Code editor wrapper', customCssKey: 'container' },
{ selector: '.wm-code-editor .monaco-editor-background', comment: 'Editor background' },
{ selector: '.wm-code-editor .monaco-editor .line-numbers', comment: 'Line numbers' },
{
selector: '.wm-code-editor .monaco-editor .current-line',
comment: 'Current line highlight'
}
],
variables: []
},
{
components: ['chartjscomponent'],
selectors: [{ selector: '.wm-chartjs', comment: 'ChartJS', customCssKey: 'container' }],
@@ -593,6 +593,9 @@ export const quickStyleProperties: Record<
textcomponent: {
text: [typographyGrouping]
},
codeinputcomponent: {
container: inputDefaultProps
},
imagecomponent: {
image: containerDefaultProps
},