feat(frontend): templatable editor with autocompletion

This commit is contained in:
Ruben Fiszel
2022-12-22 08:38:12 +01:00
parent 611d90f7cb
commit 962c14639c
29 changed files with 5433 additions and 104 deletions
+4
View File
@@ -39,6 +39,10 @@
.monaco-editor textarea:focus {
box-shadow: none !important;
}
.monaco-editor span.mtk20 {
color: black !important;
}
}
@layer components {
@@ -14,6 +14,9 @@
import type VariableEditor from './VariableEditor.svelte'
import type ItemPicker from './ItemPicker.svelte'
import type { InputTransform } from '$lib/gen'
import TemplateEditor from './TemplateEditor.svelte'
import Tooltip from './Tooltip.svelte'
import { escape } from 'svelte/internal'
export let schema: Schema
export let arg: InputTransform | any
@@ -99,6 +102,10 @@
}
const { focusProp, propPickerConfig } = getContext<PropPickerWrapperContext>('PropPickerWrapper')
$: isStaticTemplate(inputCat) && propertyType == 'static' && setPropertyType(arg.value)
const openBracket = '${'
const closeBracket = '}'
</script>
{#if arg != undefined}
@@ -174,7 +181,11 @@
>
{#if isStaticTemplate(inputCat)}
<ToggleButton light position="left" value="static" size="xs">
{'${} '}Templatable</ToggleButton
{'${} '}Templatable &nbsp; <Tooltip
>Write javascript expressions between "{openBracket}" and "{closeBracket}". You may
refer to contextual objects like 'flow_input', or 'result' or functions like
'resource' and 'variable'
</Tooltip></ToggleButton
>
{:else}
<ToggleButton light position="left" value="static" size="xs">Static</ToggleButton>
@@ -207,7 +218,11 @@
Connect input &rightarrow;
</span>
{/if}
{#if propertyType === undefined || propertyType == 'static'}
{#if isStaticTemplate(inputCat) && propertyType == 'static'}
<div class="py-1">
<TemplateEditor {extraLib} on:focus={onFocus} bind:code={arg.value} />
</div>
{:else if propertyType === undefined || propertyType == 'static'}
<ArgInput
noMargin
compact
@@ -229,11 +244,6 @@
properties={schema.properties[argName].properties}
displayHeader={false}
bind:inputCat
on:input={(e) => {
if (isStaticTemplate(inputCat)) {
setPropertyType(e.detail.rawValue)
}
}}
{variableEditor}
{itemPicker}
bind:pickForField
+1 -1
View File
@@ -299,7 +299,7 @@
} else if (kind === 'group') {
meta.owner = 'all'
} else {
meta.owner = $userStore?.username ?? ''
meta.owner = $userStore?.username?.split('@')[0] ?? ''
}
}
}}
@@ -1,5 +1,6 @@
<script lang="ts" context="module">
import * as monaco from 'monaco-editor'
import libStdContent from '$lib/es5.d.ts.txt?raw'
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.Latest,
@@ -156,15 +157,19 @@
dispatch('blur')
})
if (lang == 'javascript' && extraLib != '') {
monaco.languages.typescript.javascriptDefaults.setExtraLibs([
{
content: extraLib,
filePath: 'windmill.d.ts'
}
])
} else {
monaco.languages.typescript.javascriptDefaults.setExtraLibs([])
if (lang == 'javascript') {
const stdLib = { content: libStdContent, filePath: 'es5.d.ts' }
if (extraLib != '') {
monaco.languages.typescript.javascriptDefaults.setExtraLibs([
{
content: extraLib,
filePath: 'windmill.d.ts'
},
stdLib
])
} else {
monaco.languages.typescript.javascriptDefaults.setExtraLibs([stdLib])
}
}
}
@@ -0,0 +1,577 @@
<script lang="ts" context="module">
import * as monaco from 'monaco-editor'
import libStdContent from '$lib/es5.d.ts.txt?raw'
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.Latest,
allowNonTsExtensions: true,
noLib: true
})
monaco.languages.register({ id: 'template' })
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' }
]
}
}
// Register a tokens provider for the language
monaco.languages.registerTokensProviderFactory('template', {
create: () => language as monaco.languages.IMonarchLanguage
})
monaco.languages.setLanguageConfiguration('template', conf)
// monaco.languages.typescript.getTypeScriptWorker()
// Register a completion item provider for the new language
</script>
<script lang="ts">
import { browser, dev } from '$app/env'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { buildWorkerDefinition } from 'monaco-editor-workers'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import {
convertKind,
createDocumentationString,
createHash,
displayPartsToString,
editorConfig,
updateOptions
} from '$lib/editorUtils'
let divEl: HTMLDivElement | null = null
let editor: monaco.editor.IStandaloneCodeEditor
let model: monaco.editor.ITextModel
export let code: string = ''
export let hash: string = createHash()
export let automaticLayout = true
export let extraLib: string = ''
export let autoHeight = true
export let fixedOverflowWidgets = true
const lang = 'template'
const dispatch = createEventDispatcher()
const uri = `file:///${hash}.ts`
if (browser) {
if (dev) {
buildWorkerDefinition(
'../../../node_modules/monaco-editor-workers/dist/workers',
import.meta.url,
false
)
} else {
// @ts-ignore
self.MonacoEnvironment = {
getWorker: function (_moduleId: any, label: string) {
return new tsWorker()
}
}
}
}
export function getCode(): string {
return editor?.getValue() ?? ''
}
let cip
let extraModel
let width = 0
async function loadMonaco() {
model = monaco.editor.createModel(code, lang, monaco.Uri.parse(uri))
model.updateOptions(updateOptions)
editor = monaco.editor.create(divEl as HTMLDivElement, {
...editorConfig(model, code, lang, automaticLayout, fixedOverflowWidgets),
lineNumbers: 'off',
fontSize: 16,
suggestOnTriggerCharacters: true
})
const stdLib = { content: libStdContent, filePath: 'es5.d.ts' }
if (extraLib != '') {
monaco.languages.typescript.javascriptDefaults.setExtraLibs([
{
content: extraLib,
filePath: 'windmill.d.ts'
},
stdLib
])
} else {
monaco.languages.typescript.javascriptDefaults.setExtraLibs([stdLib])
}
extraModel = monaco.editor.createModel('`' + model.getValue() + '`', 'javascript')
const worker = await monaco.languages.typescript.getJavaScriptWorker()
const client = await worker(extraModel.uri)
cip = monaco.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 monaco.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 monaco.Range(p1.lineNumber, p1.column, p2.lineNumber, p2.column)
}
const tags: monaco.languages.CompletionItemTag[] = []
if (entry.kindModifiers?.indexOf('deprecated') !== -1) {
tags.push(monaco.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: monaco.languages.CompletionItem, token: any) => {
extraModel.setValue('`' + model.getValue() + '`')
const myItem = <any>item
const position = myItem.position
const offset = myItem.offset
const details = await client.getCompletionEntryDetails(
extraModel.uri.toString(),
offset,
myItem.label
)
if (!details) {
return myItem
}
return <any>{
uri: model.uri,
position: position,
label: details.name,
kind: convertKind(details.kind),
detail: displayPartsToString(details.displayParts),
documentation: {
value: createDocumentationString(details)
}
}
}
})
let timeoutModel: NodeJS.Timeout | undefined = undefined
editor.onDidChangeModelContent((event) => {
timeoutModel && clearTimeout(timeoutModel)
timeoutModel = setTimeout(() => {
code = getCode()
dispatch('change', { code })
}, 200)
})
if (autoHeight) {
let ignoreEvent = false
const updateHeight = () => {
const contentHeight = Math.min(1000, editor.getContentHeight())
if (divEl) {
divEl.style.height = `${contentHeight}px`
}
try {
ignoreEvent = true
editor.layout({ width, height: contentHeight })
} finally {
ignoreEvent = false
}
}
editor.onDidContentSizeChange(updateHeight)
updateHeight()
}
editor.onDidFocusEditorText(() => {
dispatch('focus')
})
editor.onDidBlurEditorText(() => {
code = getCode()
dispatch('blur')
})
}
onMount(() => {
if (browser) {
loadMonaco()
}
})
onDestroy(() => {
try {
model && model.dispose()
editor && editor.dispose()
cip && cip.dispose()
extraModel && extraModel.dispose()
} catch (err) {}
})
</script>
<div bind:this={divEl} class="{$$props.class} editor" bind:clientWidth={width} />
<style>
.editor {
@apply rounded-lg mx-0.5;
}
</style>
@@ -26,9 +26,9 @@
let runnableComponent: RunnableComponent
</script>
<InputValue input={configuration.label} bind:value={labelValue} />
<InputValue input={configuration.color} bind:value={color} />
<InputValue input={configuration.size} bind:value={size} />
<InputValue {id} input={configuration.label} bind:value={labelValue} />
<InputValue {id} input={configuration.color} bind:value={color} />
<InputValue {id} input={configuration.size} bind:value={size} />
<RunnableWrapper
bind:runnableComponent
@@ -64,8 +64,8 @@
}
</script>
<InputValue input={configuration.theme} bind:value={theme} />
<InputValue input={configuration.labels} bind:value={labels} />
<InputValue {id} input={configuration.theme} bind:value={theme} />
<InputValue {id} input={configuration.labels} bind:value={labels} />
<RunnableWrapper bind:componentInput {id} bind:result>
{#if result}
@@ -63,8 +63,8 @@
}
</script>
<InputValue input={configuration.theme} bind:value={theme} />
<InputValue input={configuration.labels} bind:value={labels} />
<InputValue {id} input={configuration.theme} bind:value={theme} />
<InputValue {id} input={configuration.labels} bind:value={labels} />
<RunnableWrapper bind:componentInput {id} bind:result>
{#if result}
@@ -27,9 +27,9 @@
}
</script>
<InputValue input={configuration.label} bind:value={labelValue} />
<InputValue input={configuration.minDate} bind:value={minValue} />
<InputValue input={configuration.maxDate} bind:value={maxValue} />
<InputValue {id} input={configuration.label} bind:value={labelValue} />
<InputValue {id} input={configuration.minDate} bind:value={minValue} />
<InputValue {id} input={configuration.maxDate} bind:value={maxValue} />
<AlignWrapper {verticalAlignment}>
<input
@@ -25,9 +25,9 @@
let runnableComponent: RunnableComponent
</script>
<InputValue input={configuration.label} bind:value={labelValue} />
<InputValue input={configuration.color} bind:value={color} />
<InputValue input={configuration.size} bind:value={size} />
<InputValue {id} input={configuration.label} bind:value={labelValue} />
<InputValue {id} input={configuration.color} bind:value={color} />
<InputValue {id} input={configuration.size} bind:value={size} />
<RunnableWrapper
bind:runnableComponent
@@ -1,4 +1,5 @@
<script lang="ts">
import { isCodeInjection } from '$lib/components/flows/utils'
import { getContext } from 'svelte'
import type { AppInput } from '../../inputType'
import type { AppEditorContext } from '../../types'
@@ -8,25 +9,79 @@
export let input: AppInput
export let value: T
export let id: string | undefined = undefined
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
$: state = $worldStore?.state
$: input && $worldStore && handleConnection()
$: input && $state && input.type == 'template' && setValue()
function handleConnection() {
if (input.type === 'connected') {
$worldStore?.connect<any>(input, onValueChange)
} else if (input.type === 'static') {
$worldStore?.connect<any>(input, onValueChange, value)
} else if (input.type === 'static' || input.type == 'template') {
setValue()
} else {
value = undefined
}
}
function setValue() {
if (input.type === 'static') {
value = input.value
function computeGlobalContext() {
Object.prototype.toString = function () {
return JSON.stringify(this)
}
return Object.fromEntries(
Object.entries($worldStore?.outputsById ?? {})
.filter(([k, _]) => k != id)
.map(([key, value]) => {
return [
key,
Object.fromEntries(Object.entries(value ?? {}).map((x) => [x[0], x[1].peak()]))
]
})
)
}
function setValue() {
console.log(computeGlobalContext())
if (input.type === 'template' && isCodeInjection(input.eval)) {
try {
value = eval_like('`' + input.eval + '`', computeGlobalContext())
} catch (e) {
value = e.message
}
} else if (input.type === 'static') {
value = input.value
} else if (input.type === 'template') {
value = input.eval
}
}
function create_context_function_template(eval_string, context) {
return `
return function (context) {
"use strict";
${
Object.keys(context).length > 0
? `let ${Object.keys(context).map((key) => ` ${key} = context['${key}']`)};`
: ``
}
return ${eval_string};
}
`
}
function make_context_evaluator(eval_string, context) {
let template = create_context_function_template(eval_string, context)
let functor = Function(template)
return functor()
}
function eval_like(text, context = {}) {
let evaluator = make_context_evaluator(text, context)
return evaluator(context)
}
function onValueChange(newValue: any): void {
@@ -26,7 +26,7 @@
</script>
{#if componentInput.type !== 'runnable'}
<InputValue input={componentInput} bind:value={result} />
<InputValue {id} input={componentInput} bind:value={result} />
{/if}
<slot />
@@ -199,7 +199,7 @@
</script>
{#each Object.keys(inputs ?? {}) as key}
<InputValue input={inputs[key]} bind:value={runnableInputValues[key]} />
<InputValue {id} input={inputs[key]} bind:value={runnableInputValues[key]} />
{/each}
<TestJobLoader
@@ -3,7 +3,6 @@
import type { AppInput } from '../../inputType'
import type { Output } from '../../rx'
import type { AppEditorContext } from '../../types'
import DebouncedInput from '../helpers/DebouncedInput.svelte'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
import InputValue from '../helpers/InputValue.svelte'
@@ -29,7 +28,7 @@
}
</script>
<InputValue input={configuration.label} bind:value={labelValue} />
<InputValue {id} input={configuration.label} bind:value={labelValue} />
<AlignWrapper {verticalAlignment}>
<input
@@ -27,7 +27,7 @@
}
</script>
<InputValue input={configuration.label} bind:value={labelValue} />
<InputValue {id} input={configuration.label} bind:value={labelValue} />
<AlignWrapper {horizontalAlignment} {verticalAlignment}>
<Toggle
@@ -27,9 +27,9 @@
}
</script>
<InputValue input={configuration.label} bind:value={label} />
<InputValue input={configuration.items} bind:value={items} />
<InputValue input={configuration.itemKey} bind:value={itemKey} />
<InputValue {id} input={configuration.label} bind:value={label} />
<InputValue {id} input={configuration.items} bind:value={items} />
<InputValue {id} input={configuration.itemKey} bind:value={itemKey} />
<AlignWrapper {horizontalAlignment} {verticalAlignment}>
<Select on:clear={onChange} on:change={onChange} {items} placeholder="Select an item" />
@@ -100,8 +100,8 @@
$: result && rerender()
</script>
<InputValue input={configuration.search} bind:value={search} />
<InputValue input={configuration.pagination} bind:value={pagination} />
<InputValue {id} input={configuration.search} bind:value={search} />
<InputValue {id} input={configuration.pagination} bind:value={pagination} />
<RunnableWrapper bind:componentInput {id} bind:result {extraQueryParams}>
{#if Array.isArray(result) && result.every(isObject)}
@@ -25,7 +25,7 @@
}
</script>
<InputValue input={configuration.label} bind:value={labelValue} />
<InputValue {id} input={configuration.label} bind:value={labelValue} />
<AlignWrapper {verticalAlignment}>
<input
@@ -65,7 +65,7 @@
mounted = true
})
$: mounted && ($worldStore = buildWorld($staticOutputs))
$: mounted && ($worldStore = buildWorld($staticOutputs, $worldStore))
$: previewing = $mode === 'preview'
$: width = $breakpoint === 'sm' ? 'w-[640px]' : 'min-w-[1080px] w-full'
@@ -89,7 +89,7 @@
<Pane size={20}>
<ContextPanel />
</Pane>
<Pane size={60}>
<Pane size={55}>
<SplitPanesWrapper horizontal>
<Pane size={70}>
<div class={classNames('bg-gray-100 mx-auto relative min-h-full', width)}>
@@ -110,7 +110,7 @@
</Pane>
</SplitPanesWrapper>
</Pane>
<Pane size={20} minSize={20} maxSize={20}>
<Pane size={25} minSize={20} maxSize={33}>
<Tabs bind:selected={selectedTab}>
<Tab value="insert" size="xs">
<div class="m-1 flex flex-row gap-2">
@@ -49,7 +49,7 @@
mounted = true
})
$: mounted && ($worldStore = buildWorld($staticOutputs))
$: mounted && ($worldStore = buildWorld($staticOutputs, undefined))
$: width = $breakpoint === 'sm' ? 'w-[640px]' : 'w-full '
</script>
@@ -34,7 +34,7 @@
</script>
<PanelSection title="Outputs">
{#each Object.entries($staticOutputs) as [componentId, outputs], index}
{#each Object.entries($staticOutputs) as [componentId, outputs] (componentId)}
{#if outputs.length > 0 && $worldStore?.outputsById[componentId]}
<div class="flex flex-row justify-between w-full -mb-2 ">
<button
@@ -5,20 +5,34 @@
export let componentInput: AppInput
export let disableStatic: boolean = false
$: if (componentInput.fieldType == 'textarea' && componentInput.type == 'static') {
//@ts-ignore
componentInput.type = 'template'
}
const brackets = '${}'
</script>
{#if componentInput.fieldType !== 'any'}
<div class="w-full">
<ToggleButtonGroup bind:selected={componentInput.type}>
<ToggleButton
position="left"
value="static"
startIcon={{ icon: faPen }}
size="xs"
disable={disableStatic}
>
Static
</ToggleButton>
{#if componentInput.fieldType === 'textarea'}
<ToggleButton position="left" value="template" size="xs" disable={disableStatic}>
{brackets} Templatable
</ToggleButton>
{:else}
<ToggleButton
position="left"
value="static"
startIcon={{ icon: faPen }}
size="xs"
disable={disableStatic}
>
Static
</ToggleButton>
{/if}
<ToggleButton
value="connected"
position="center"
@@ -12,17 +12,17 @@
import { capitalize } from '$lib/utils'
import { fieldTypeToTsType } from '../../utils'
import Recompute from './Recompute.svelte'
import gridHelp from 'svelte-grid/build/helper/index.mjs'
import { gridColumns } from '../../gridUtils'
import Tooltip from '$lib/components/Tooltip.svelte'
import ComponentInputTypeEditor from './ComponentInputTypeEditor.svelte'
import AlignmentEditor from './AlignmentEditor.svelte'
import RunnableInputEditor from './inputEditor/RunnableInputEditor.svelte'
import TemplateEditor from '$lib/components/TemplateEditor.svelte'
import type { Output } from '../../rx'
export let component: AppComponent | undefined
export let onDelete: (() => void) | undefined = undefined
const { app, staticOutputs, runnableComponents } =
const { app, staticOutputs, runnableComponents, worldStore } =
getContext<AppEditorContext>('AppEditorContext')
function removeGridElement() {
@@ -48,6 +48,25 @@
}
}
}
export function buildExtraLib(components: Record<string, Record<string, Output<any>>>): string {
return Object.entries(components)
.filter(([k, v]) => k != component?.id)
.map(([k, v]) => [k, Object.fromEntries(Object.entries(v).map(([k, v]) => [k, v.peak()]))])
.map(
([k, v]) => `
declare const ${k} = ${JSON.stringify(v)};
`
)
.join('\n')
}
$: extraLib =
component?.componentInput?.type === 'template' && $worldStore
? buildExtraLib($worldStore?.outputsById ?? {})
: undefined
</script>
{#if component}
@@ -71,6 +90,8 @@
<div class="flex flex-col w-full gap-2 my-2">
{#if component.componentInput.type === 'static'}
<StaticInputEditor bind:componentInput={component.componentInput} />
{:else if component.componentInput.type === 'template' && component.componentInput !== undefined}
<TemplateEditor bind:code={component.componentInput.eval} {extraLib} />
{:else if component.componentInput.type === 'connected' && component.componentInput !== undefined}
<ConnectedInputEditor bind:componentInput={component.componentInput} />
{:else if component.componentInput?.type === 'runnable' && component.componentInput !== undefined}
@@ -17,7 +17,7 @@
{#if componentInput.fieldType === 'number'}
<input type="number" bind:value={componentInput.value} />
{:else if componentInput.fieldType === 'textarea'}
<textarea bind:value={componentInput.value} />
<textarea type="text" bind:value={componentInput.value} />
{:else if componentInput.fieldType === 'boolean'}
<Toggle bind:checked={componentInput.value} />
{:else if componentInput.fieldType === 'select'}
+13 -7
View File
@@ -40,6 +40,11 @@ export type StaticInput<U> = {
visible?: boolean | undefined
}
export type TemplateInput = {
eval: string
type: 'template'
}
type RunnableByPath = {
path: string
runType: 'script' | 'flow' | 'hubscript'
@@ -66,6 +71,7 @@ type AppInputSpec<T extends InputType, U, V extends InputType = never> = (
| ConnectedInput
| UserInput<U>
| ResultInput
| TemplateInput
) &
InputConfiguration<T, U, V>
@@ -86,11 +92,11 @@ export type AppInput =
| AppInputSpec<'any', any>
| AppInputSpec<'object', Record<string | number, any>>
| (AppInputSpec<'select', string> & {
/**
* One of the keys of `staticValues` from `lib/components/apps/editor/componentsPanel/componentStaticValues`
*/
optionValuesKey: keyof typeof staticValues
})
/**
* One of the keys of `staticValues` from `lib/components/apps/editor/componentsPanel/componentStaticValues`
*/
optionValuesKey: keyof typeof staticValues
})
| AppInputSpec<'array', string[], 'text'>
| AppInputSpec<'array', string[], 'textarea'>
| AppInputSpec<'array', number[], 'number'>
@@ -100,8 +106,8 @@ export type AppInput =
| AppInputSpec<'array', string[], 'datetime'>
| AppInputSpec<'array', object[], 'object'>
| (AppInputSpec<'array', string[], 'select'> & {
optionValuesKey: keyof typeof staticValues
})
optionValuesKey: keyof typeof staticValues
})
export type StaticAppInput = Extract<AppInput, { type: 'static' }>
export type ConnectedAppInput = Extract<AppInput, { type: 'connected' }>
+29 -15
View File
@@ -1,4 +1,5 @@
import type { AppInput } from './inputType'
import { writable, type Writable } from 'svelte/store'
export interface Subscriber<T> {
next(v: T)
@@ -9,40 +10,44 @@ export interface Observable<T> {
}
export interface Output<T> extends Observable<T> {
set(x: T, force?: boolean): void
peak(): T | any | undefined
}
export interface Input<T> extends Subscriber<T> {
peak(): T | any | undefined
}
export type World = {
outputsById: Record<string, Record<string, Output<any>>>
connect: <T>(inputSpec: AppInput, next: (x: T) => void) => Input<T>
connect: <T>(inputSpec: AppInput, next: (x: T) => void, previousValue: T) => Input<T>
state: Writable<number>
}
export function buildWorld(components: Record<string, string[]>) {
export function buildWorld(components: Record<string, string[]>, previousWorld: World | undefined): World {
const newWorld = buildObservableWorld()
const outputsById: Record<string, Record<string, Output<any>>> = {}
const state = writable(0)
for (const [k, outputs] of Object.entries(components)) {
outputsById[k] = {}
for (const o of outputs) {
outputsById[k][o] = newWorld.newOutput(k, o)
outputsById[k][o] = newWorld.newOutput(k, o, state, previousWorld?.outputsById[k]?.[o].peak())
}
}
state.update((x) => x + 1)
return { outputsById, connect: newWorld.connect }
return { outputsById, connect: newWorld.connect, state }
}
export function buildObservableWorld() {
const observables: Record<string, Output<any>> = {}
function connect<T>(inputSpec: AppInput, next: (x: T) => void): Input<T> {
function connect<T>(inputSpec: AppInput, next: (x: T) => void, previousValue: T): Input<T> {
if (inputSpec.type === 'static') {
return {
peak: () => inputSpec.value,
next: () => {}
next: () => { }
}
} else if (inputSpec.type === 'connected') {
const input = cachedInput(next)
@@ -52,7 +57,7 @@ export function buildObservableWorld() {
if (!connection) {
return {
peak: () => undefined,
next: () => {}
next: () => { }
}
}
@@ -66,7 +71,7 @@ export function buildObservableWorld() {
console.warn('Observable at ' + componentId + '.' + p + ' not found')
return {
peak: () => undefined,
next: () => {}
next: () => { }
}
}
@@ -75,15 +80,16 @@ export function buildObservableWorld() {
} else if (inputSpec.type === 'user') {
return {
peak: () => inputSpec.value,
next: () => {}
next: () => { }
}
} else {
throw Error('Unknown input type ' + inputSpec)
}
}
function newOutput<T>(id: string, name: string): Output<T> {
const output = settableOutput<T>()
function newOutput<T>(id: string, name: string, state: Writable<number>, previousValue: T): Output<T> {
const output = settableOutput<T>(state, previousValue)
observables[`${id}.${name}`] = output
return output
}
@@ -111,12 +117,13 @@ export function cachedInput<T>(nextParan: (x: T) => void): Input<T> {
}
}
export function settableOutput<T>(): Output<T> {
let value: T | undefined = undefined
export function settableOutput<T>(state: Writable<number>, previousValue: T): Output<T> {
let value: T | undefined = previousValue
const subscribers: Subscriber<T>[] = []
function subscribe(x: Subscriber<T>) {
if (!subscribers.includes(x)) {
subscribers.push(x)
// Send the current value to the new subscriber if it already exists
@@ -128,14 +135,21 @@ export function settableOutput<T>(): Output<T> {
function set(x: T, force: boolean = false) {
if (value != x || force) {
state.update((x) => x + 1)
value = x
subscribers.forEach((x) => x.next(value!))
}
}
function peak(): T | undefined {
return value
}
return {
subscribe,
set
set,
peak
}
}
@@ -25,6 +25,18 @@
<Icon data={faPlus} scale={0.8} />
</button>
<div class="divide-y divide-gray-100 text-xs w-40">
<button
class="w-full text-left p-2 hover:bg-gray-100"
on:click={() => {
close()
dispatch('new', 'script')
}}
role="menuitem"
tabindex="-1"
>
<Icon data={faCode} scale={0.8} class="mr-1" />
Action (Script)
</button>
{#if trigger}
<button
class="w-full text-left p-2 hover:bg-gray-100"
@@ -39,18 +51,6 @@
Trigger (Script)
</button>
{/if}
<button
class="w-full text-left p-2 hover:bg-gray-100"
on:click={() => {
close()
dispatch('new', 'script')
}}
role="menuitem"
tabindex="-1"
>
<Icon data={faCode} scale={0.8} class="mr-1" />
Action (Script)
</button>
<button
class="w-full text-left p-2 hover:bg-gray-100"
on:click={() => {
+104 -6
View File
@@ -1,6 +1,6 @@
import { languages } from 'monaco-editor/esm/vs/editor/editor.api'
export function editorConfig(model: any, code: string, lang: string, automaticLayout: boolean, fixedOverflowWidgets: boolean) {
return {
model,
value: code,
@@ -10,10 +10,9 @@ export function editorConfig(model: any, code: string, lang: string, automaticLa
fixedOverflowWidgets,
autoDetectHighContrast: true,
//lineNumbers: 'off',
//lineDecorationsWidth: 0,
lineNumbersMinChars: 4,
lineDecorationsWidth: 15,
lineNumbersMinChars: 2,
scrollbar: { alwaysConsumeMouseWheel: false },
lineNumbers: (ln) => '<span class="pr-4 text-gray-400">' + ln + '</span>',
folding: false,
scrollBeyondLastLine: false,
minimap: {
@@ -22,6 +21,9 @@ export function editorConfig(model: any, code: string, lang: string, automaticLa
lightbulb: {
enabled: true
},
suggest: {
showKeywords: false,
},
'bracketPairColorization.enabled': true,
matchBrackets: 'always' as 'always',
}
@@ -52,4 +54,100 @@ export function langToExt(lang: string): string {
}
}
export const updateOptions = { tabSize: 2, insertSpaces: true }
export const updateOptions = { tabSize: 2, insertSpaces: true }
export function convertKind(kind: string): any {
switch (kind) {
case Kind.primitiveType:
case Kind.keyword:
return languages.CompletionItemKind.Keyword;
case Kind.variable:
case Kind.localVariable:
return languages.CompletionItemKind.Variable;
case Kind.memberVariable:
case Kind.memberGetAccessor:
case Kind.memberSetAccessor:
return languages.CompletionItemKind.Field;
case Kind.function:
case Kind.memberFunction:
case Kind.constructSignature:
case Kind.callSignature:
case Kind.indexSignature:
return languages.CompletionItemKind.Function;
case Kind.enum:
return languages.CompletionItemKind.Enum;
case Kind.module:
return languages.CompletionItemKind.Module;
case Kind.class:
return languages.CompletionItemKind.Class;
case Kind.interface:
return languages.CompletionItemKind.Interface;
case Kind.warning:
return languages.CompletionItemKind.File;
}
return languages.CompletionItemKind.Property;
}
class Kind {
public static unknown: string = '';
public static keyword: string = 'keyword';
public static script: string = 'script';
public static module: string = 'module';
public static class: string = 'class';
public static interface: string = 'interface';
public static type: string = 'type';
public static enum: string = 'enum';
public static variable: string = 'var';
public static localVariable: string = 'local var';
public static function: string = 'function';
public static localFunction: string = 'local function';
public static memberFunction: string = 'method';
public static memberGetAccessor: string = 'getter';
public static memberSetAccessor: string = 'setter';
public static memberVariable: string = 'property';
public static constructorImplementation: string = 'constructor';
public static callSignature: string = 'call';
public static indexSignature: string = 'index';
public static constructSignature: string = 'construct';
public static parameter: string = 'parameter';
public static typeParameter: string = 'type parameter';
public static primitiveType: string = 'primitive type';
public static label: string = 'label';
public static alias: string = 'alias';
public static const: string = 'const';
public static let: string = 'let';
public static warning: string = 'warning';
}
export function createDocumentationString(details: any): string {
let documentationString = displayPartsToString(details.documentation);
if (details.tags) {
for (const tag of details.tags) {
documentationString += `\n\n${tagToString(tag)}`;
}
}
return documentationString;
}
function tagToString(tag: any): string {
let tagLabel = `*@${tag.name}*`;
if (tag.name === 'param' && tag.text) {
const [paramName, ...rest] = tag.text;
tagLabel += `\`${paramName.text}\``;
if (rest.length > 0) tagLabel += `${rest.map((r) => r.text).join(' ')}`;
} else if (Array.isArray(tag.text)) {
tagLabel += `${tag.text.map((r) => r.text).join(' ')}`;
} else if (tag.text) {
tagLabel += `${tag.text}`;
}
return tagLabel;
}
export function displayPartsToString(displayParts: any | undefined): string {
if (displayParts) {
return displayParts.map((displayPart) => displayPart.text).join('');
}
return '';
}
File diff suppressed because it is too large Load Diff