Procházet zdrojové kódy

feat: 添加拖拽,代码自定义提示功能

jiaxing.liao před 1 měsícem
rodič
revize
98288c7f99

+ 87 - 4
apps/web/src/features/setter/index.vue

@@ -1,5 +1,5 @@
 <script lang="ts" setup>
-import { computed, provide, ref, watch } from 'vue'
+import { computed, onBeforeUnmount, onMounted, provide, reactive, ref, watch } from 'vue'
 import { Icon, Input } from '@repo/ui'
 import { useDebounceFn } from '@vueuse/core'
 import { agent } from '@repo/api-service'
@@ -32,7 +32,9 @@ const emit = defineEmits<{
 
 const { t } = useI18n()
 
-const node = computed<IWorkflowNode>(() => props.workflow.nodes.find((item) => item.id === props.id)!)
+const node = computed<IWorkflowNode>(
+	() => props.workflow.nodes.find((item) => item.id === props.id)!
+)
 
 const setter = computed(() => {
 	return node.value?.data?.nodeType
@@ -64,6 +66,52 @@ const name = ref(node.value?.name || '')
 const remark = ref(node.value?.remark || '')
 const nodeVars = ref<NodeVar[]>([])
 const currentTab = ref<'setting' | 'last-run'>(props.activeTab || 'setting')
+const MIN_DRAWER_WIDTH = 420
+const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
+const drawerWidth = ref(MIN_DRAWER_WIDTH)
+const resizeState = reactive({
+	startX: 0,
+	startWidth: MIN_DRAWER_WIDTH,
+	isDragging: false
+})
+
+const maxDrawerWidth = computed(() => {
+	return Math.max(MIN_DRAWER_WIDTH, Math.floor(viewportWidth.value * 0.6))
+})
+
+const clampDrawerWidth = (width: number) => {
+	return Math.min(Math.max(width, MIN_DRAWER_WIDTH), maxDrawerWidth.value)
+}
+
+const syncViewportWidth = () => {
+	viewportWidth.value = window.innerWidth
+	drawerWidth.value = clampDrawerWidth(drawerWidth.value)
+}
+
+const stopResize = () => {
+	if (!resizeState.isDragging) return
+	resizeState.isDragging = false
+	document.body.style.userSelect = ''
+	document.body.style.cursor = ''
+	window.removeEventListener('mousemove', onResize)
+	window.removeEventListener('mouseup', stopResize)
+}
+
+const onResize = (event: MouseEvent) => {
+	if (!resizeState.isDragging) return
+	const nextWidth = resizeState.startWidth + (resizeState.startX - event.clientX)
+	drawerWidth.value = clampDrawerWidth(nextWidth)
+}
+
+const onResizeStart = (event: MouseEvent) => {
+	resizeState.startX = event.clientX
+	resizeState.startWidth = drawerWidth.value
+	resizeState.isDragging = true
+	document.body.style.userSelect = 'none'
+	document.body.style.cursor = 'ew-resize'
+	window.addEventListener('mousemove', onResize)
+	window.addEventListener('mouseup', stopResize)
+}
 
 const onUpdateName = () => {
 	if (name.value !== node.value?.name && name.value.trim() !== '') {
@@ -99,12 +147,31 @@ watch(
 	}
 )
 
+onMounted(() => {
+	window.addEventListener('resize', syncViewportWidth)
+	syncViewportWidth()
+})
+
+onBeforeUnmount(() => {
+	stopResize()
+	window.removeEventListener('resize', syncViewportWidth)
+})
+
 provide('nodeVars', nodeVars)
 </script>
 
 <template>
 	<div class="setter">
-		<div class="drawer shadow-2xl" :class="{ 'drawer--open': props.visible && setter }">
+		<div
+			class="drawer shadow-2xl"
+			:class="{
+				'drawer--open': props.visible && setter,
+				'drawer--resizing': resizeState.isDragging
+			}"
+			:style="{ width: `${drawerWidth}px`, maxWidth: '60vw' }"
+		>
+			<!-- Resize handle -->
+			<div class="resize-handle" @mousedown.prevent="onResizeStart"></div>
 			<header class="text-gray-800">
 				<div class="w-full flex items-center justify-between">
 					<h4 class="flex items-center">
@@ -184,7 +251,7 @@ provide('nodeVars', nodeVars)
 		top: 60px;
 		right: 5px;
 		bottom: 10px;
-		width: 420px;
+		min-width: 420px;
 		background: #fff;
 		z-index: 1000;
 		border-radius: 8px;
@@ -193,6 +260,22 @@ provide('nodeVars', nodeVars)
 		border: 1px solid #e4e4e4;
 		transform: translateX(110%);
 		transition: transform 0.25s ease;
+
+		&.drawer--resizing {
+			transition: none;
+		}
+
+		.resize-handle {
+			position: absolute;
+			left: -4px;
+			top: 50%;
+			transform: translateY(-50%);
+			width: 3px;
+			height: 32px;
+			background-color: #c1c4cb;
+			border-radius: 8px;
+			cursor: ew-resize;
+		}
 	}
 
 	.drawer--open {

+ 59 - 2
apps/web/src/nodes/_base/CodeEditor.vue

@@ -7,6 +7,12 @@ import * as monaco from 'monaco-editor'
 import { IconButton } from '@repo/ui'
 import { debounce } from 'lodash-es'
 import { useI18n } from '@/composables/useI18n'
+import {
+	clearCodeEditorCompletionConfig,
+	shouldTriggerCodeEditorCompletion,
+	syncCodeEditorCompletionConfig,
+	type CodeEditorCompletionConfig
+} from './codeEditorCompletion'
 
 interface CodeEditorType {
 	// 是否展示工具栏
@@ -37,6 +43,7 @@ interface CodeEditorType {
 	config?: editor.IStandaloneEditorConstructionOptions
 	// 是否可以切换语言
 	allowChangeLanguage?: boolean
+	completionConfig?: CodeEditorCompletionConfig
 }
 
 const props = withDefaults(defineProps<CodeEditorType>(), {
@@ -52,6 +59,7 @@ const props = withDefaults(defineProps<CodeEditorType>(), {
 	theme: 'vs-light',
 	formatValue: 'string',
 	height: 150,
+	completionConfig: () => ({}),
 	config: () => ({
 		minimap: { enabled: false },
 		selectOnLineNumbers: true
@@ -85,7 +93,8 @@ let componentConfig = reactive({
 const languageSource = [
 	{ id: 'javascript', name: 'javascript' },
 	{ id: 'python', name: 'python' },
-	{ id: 'json', name: 'json' }
+	{ id: 'json', name: 'json' },
+	{ id: 'java', name: 'java' }
 ]
 
 /**
@@ -99,9 +108,15 @@ const formatValue = (value: any) => {
 	return value ?? ''
 }
 
+const syncCompletionConfig = () => {
+	if (!model) return
+	syncCodeEditorCompletionConfig(model, componentConfig.language, props.completionConfig)
+}
+
 onMounted(() => {
 	// 处理代码转换
 	model = monaco.editor.createModel(formatValue(props.modelValue), componentConfig.language)
+	syncCompletionConfig()
 
 	// 接入配置
 	monacoEditor = monaco.editor.create(editContainer.value!, {
@@ -124,6 +139,36 @@ onMounted(() => {
 
 	// 监听代码输入
 	monacoEditor.onDidChangeModelContent(updateModelValue)
+	monacoEditor.onDidChangeModelContent((event) => {
+		if (!monacoEditor || !model) return
+
+		const lastChange = event.changes[event.changes.length - 1]
+		if (!lastChange?.text) return
+
+		const triggerCharacters = new Set(['.'])
+		const languageRules = props.completionConfig?.[componentConfig.language] || []
+		languageRules.forEach((rule) => {
+			;(rule.triggerCharacters || []).forEach((char) => triggerCharacters.add(char))
+		})
+
+		if (![...triggerCharacters].some((char) => lastChange.text.endsWith(char))) {
+			return
+		}
+
+		const position = monacoEditor.getPosition()
+		if (!position) return
+
+		if (
+			shouldTriggerCodeEditorCompletion(
+				model,
+				position,
+				componentConfig.language,
+				props.completionConfig
+			)
+		) {
+			monacoEditor.trigger('completion', 'editor.action.triggerSuggest', {})
+		}
+	})
 })
 
 // 读取传值数据
@@ -148,12 +193,21 @@ watch(
 		nextTick(() => {
 			if (!model) return
 			monaco.editor.setModelLanguage(model, lang)
+			syncCompletionConfig()
 			emit('update:language', lang)
 		})
 	},
 	{ immediate: true }
 )
 
+watch(
+	() => props.completionConfig,
+	() => {
+		syncCompletionConfig()
+	},
+	{ deep: true }
+)
+
 // 监听全屏, 重新计算layout
 watch(isFullScreen, () => {
 	nextTick(() => {
@@ -321,6 +375,9 @@ onBeforeUnmount(() => {
 	if (layoutFrame) {
 		cancelAnimationFrame(layoutFrame)
 	}
+	if (model) {
+		clearCodeEditorCompletionConfig(model)
+	}
 	monacoEditor?.dispose()
 	model?.dispose()
 })
@@ -495,7 +552,7 @@ defineExpose({
 			left: 50%;
 			transform: translateX(-50%);
 			width: 18px;
-			height: 6px;
+			height: 4px;
 			background-color: #d0d5dd;
 			border-radius: 8px;
 			cursor: ns-resize;

+ 219 - 0
apps/web/src/nodes/_base/codeEditorCompletion.ts

@@ -0,0 +1,219 @@
+import * as monaco from 'monaco-editor'
+
+export type CodeEditorCompletionKind =
+	| 'method'
+	| 'function'
+	| 'property'
+	| 'field'
+	| 'variable'
+	| 'class'
+	| 'keyword'
+
+export interface CodeEditorCompletionSuggestion {
+	label: string
+	insertText?: string
+	detail?: string
+	documentation?: string
+	kind?: CodeEditorCompletionKind
+	snippet?: boolean
+}
+
+export interface CodeEditorCompletionTarget {
+	object: string
+	methods?: CodeEditorCompletionSuggestion[]
+	properties?: CodeEditorCompletionSuggestion[]
+	triggerCharacters?: string[]
+}
+
+export type CodeEditorCompletionConfig = Partial<Record<string, CodeEditorCompletionTarget[]>>
+
+interface LanguageProviderState {
+	disposable: monaco.IDisposable | null
+	models: Map<string, CodeEditorCompletionTarget[]>
+}
+
+const completionKindMap: Record<CodeEditorCompletionKind, monaco.languages.CompletionItemKind> = {
+	method: monaco.languages.CompletionItemKind.Method,
+	function: monaco.languages.CompletionItemKind.Function,
+	property: monaco.languages.CompletionItemKind.Property,
+	field: monaco.languages.CompletionItemKind.Field,
+	variable: monaco.languages.CompletionItemKind.Variable,
+	class: monaco.languages.CompletionItemKind.Class,
+	keyword: monaco.languages.CompletionItemKind.Keyword
+}
+
+const languageProviderStates = new Map<string, LanguageProviderState>()
+const modelLanguageState = new Map<string, string>()
+
+const getLanguageProviderState = (language: string): LanguageProviderState => {
+	const current = languageProviderStates.get(language)
+	if (current) {
+		return current
+	}
+
+	const nextState: LanguageProviderState = {
+		disposable: null,
+		models: new Map()
+	}
+
+	languageProviderStates.set(language, nextState)
+	return nextState
+}
+
+const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+const isObjectContextMatch = (textBeforeCursor: string, objectName: string) => {
+	return new RegExp(`(?:^|[^\\w$])${escapeRegExp(objectName)}\\.\\w*$`).test(textBeforeCursor)
+}
+
+const getTriggerCharacters = (models: Map<string, CodeEditorCompletionTarget[]>) => {
+	const triggerCharacters = new Set<string>()
+
+	for (const rules of models.values()) {
+		for (const rule of rules) {
+			const nextTriggerCharacters =
+				rule.triggerCharacters && rule.triggerCharacters.length ? rule.triggerCharacters : ['.']
+
+			nextTriggerCharacters.forEach((char) => triggerCharacters.add(char))
+		}
+	}
+
+	return Array.from(triggerCharacters)
+}
+
+const toCompletionItem = (
+	suggestion: CodeEditorCompletionSuggestion,
+	range: monaco.IRange,
+	fallbackKind: monaco.languages.CompletionItemKind
+): monaco.languages.CompletionItem => {
+	return {
+		label: suggestion.label,
+		kind: suggestion.kind ? completionKindMap[suggestion.kind] : fallbackKind,
+		insertText: suggestion.insertText || suggestion.label,
+		insertTextRules: suggestion.snippet
+			? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
+			: undefined,
+		detail: suggestion.detail,
+		documentation: suggestion.documentation,
+		range
+	}
+}
+
+const buildSuggestions = (
+	rule: CodeEditorCompletionTarget,
+	range: monaco.IRange
+): monaco.languages.CompletionItem[] => {
+	const methodSuggestions = (rule.methods || []).map((item) =>
+		toCompletionItem(item, range, monaco.languages.CompletionItemKind.Method)
+	)
+	const propertySuggestions = (rule.properties || []).map((item) =>
+		toCompletionItem(item, range, monaco.languages.CompletionItemKind.Property)
+	)
+
+	return [...methodSuggestions, ...propertySuggestions]
+}
+
+const registerLanguageProvider = (language: string) => {
+	const state = getLanguageProviderState(language)
+
+	state.disposable?.dispose()
+
+	state.disposable = monaco.languages.registerCompletionItemProvider(language, {
+		triggerCharacters: getTriggerCharacters(state.models),
+		provideCompletionItems(model, position) {
+			const rules = state.models.get(model.uri.toString())
+			if (!rules?.length) {
+				return { suggestions: [] }
+			}
+
+			const word = model.getWordUntilPosition(position)
+			const range = {
+				startLineNumber: position.lineNumber,
+				endLineNumber: position.lineNumber,
+				startColumn: word.startColumn,
+				endColumn: word.endColumn
+			}
+			const textBeforeCursor = model.getValueInRange({
+				startLineNumber: position.lineNumber,
+				startColumn: 1,
+				endLineNumber: position.lineNumber,
+				endColumn: position.column
+			})
+
+			const suggestions = rules.flatMap((rule) =>
+				isObjectContextMatch(textBeforeCursor, rule.object) ? buildSuggestions(rule, range) : []
+			)
+
+			return { suggestions }
+		}
+	})
+}
+
+export const clearCodeEditorCompletionConfig = (
+	modelOrUri: monaco.editor.ITextModel | string
+) => {
+	const modelUri = typeof modelOrUri === 'string' ? modelOrUri : modelOrUri.uri.toString()
+	const language = modelLanguageState.get(modelUri)
+
+	if (!language) {
+		return
+	}
+
+	const state = languageProviderStates.get(language)
+	modelLanguageState.delete(modelUri)
+
+	if (!state) {
+		return
+	}
+
+	state.models.delete(modelUri)
+
+	if (!state.models.size) {
+		state.disposable?.dispose()
+		languageProviderStates.delete(language)
+		return
+	}
+
+	registerLanguageProvider(language)
+}
+
+export const syncCodeEditorCompletionConfig = (
+	model: monaco.editor.ITextModel,
+	language: string,
+	config?: CodeEditorCompletionConfig
+) => {
+	const modelUri = model.uri.toString()
+
+	clearCodeEditorCompletionConfig(modelUri)
+
+	const rules = config?.[language] || []
+	if (!rules.length) {
+		return
+	}
+
+	const state = getLanguageProviderState(language)
+	state.models.set(modelUri, rules)
+	modelLanguageState.set(modelUri, language)
+	registerLanguageProvider(language)
+}
+
+export const shouldTriggerCodeEditorCompletion = (
+	model: monaco.editor.ITextModel,
+	position: monaco.Position,
+	language: string,
+	config?: CodeEditorCompletionConfig
+) => {
+	const rules = config?.[language] || []
+	if (!rules.length) {
+		return false
+	}
+
+	const textBeforeCursor = model.getValueInRange({
+		startLineNumber: position.lineNumber,
+		startColumn: 1,
+		endLineNumber: position.lineNumber,
+		endColumn: position.column
+	})
+
+	return rules.some((rule) => isObjectContextMatch(textBeforeCursor, rule.object))
+}

+ 106 - 0
apps/web/src/nodes/_base/代码提示配置.md

@@ -0,0 +1,106 @@
+单条规则结构:
+
+```ts
+interface CodeEditorCompletionTarget {
+  object: string
+  methods?: CodeEditorCompletionSuggestion[]
+  properties?: CodeEditorCompletionSuggestion[]
+  triggerCharacters?: string[]
+}
+```
+
+字段说明:
+
+- `object`
+  目标对象名。当前规则只在输入 `object.` 这种上下文时生效。
+- `methods`
+  方法补全列表。
+- `properties`
+  属性补全列表。
+- `triggerCharacters`
+  触发提示的字符列表。默认是 `['.']`。
+
+方法/属性项结构:
+
+```ts
+interface CodeEditorCompletionSuggestion {
+  label: string
+  insertText?: string
+  detail?: string
+  documentation?: string
+  kind?: 'method' | 'function' | 'property' | 'field' | 'variable' | 'class' | 'keyword'
+  snippet?: boolean
+}
+```
+
+字段说明:
+
+- `label`
+  补全面板里显示的名字,必填。
+- `insertText`
+  选择后插入的文本。不传时默认插入 `label`。
+- `detail`
+  面板右侧的简短类型说明。
+- `documentation`
+  面板中的补充说明。
+- `kind`
+  指定补全项类型,影响图标显示。
+- `snippet`
+  是否按 Monaco snippet 处理。
+  设为 `true` 时可以使用 `${1:name}` 这种占位符。
+
+
+## 示例
+
+### JavaScript 示例
+
+```ts
+const completionConfig = {
+  javascript: [
+    {
+      object: 'a',
+      methods: [
+        {
+          label: 'b',
+          insertText: 'b()',
+          detail: 'function b(): void',
+          documentation: '全局对象 a 的方法'
+        }
+      ],
+      properties: [
+        {
+          label: 'version',
+          detail: 'string version'
+        }
+      ]
+    }
+  ]
+}
+```
+
+###  Java 示例
+
+```ts
+const completionConfig = {
+  java: [
+    {
+      object: 'a',
+      methods: [
+        {
+          label: 'b',
+          insertText: 'b(${1:name})',
+          detail: 'String b(String name)',
+          documentation: '全局对象 a 的方法',
+          snippet: true
+        }
+      ],
+      properties: [
+        {
+          label: 'version',
+          detail: 'String version'
+        }
+      ]
+    }
+  ]
+}
+```

+ 38 - 2
apps/web/src/nodes/src/module-invoke/setter.vue

@@ -5,8 +5,7 @@
 				<div class="section-title">{{ texts.interfaceCode }}</div>
 				<CodeEditor
 					v-model="formData.interface_code"
-					:tools="false"
-					:allow-change-language="false"
+					:completion-config="completionConfig"
 					language="javascript"
 					:height="240"
 				/>
@@ -28,6 +27,43 @@ interface Emits {
 	(e: 'update', value: ModuleInvokeData): void
 }
 
+const completionConfig = {
+	java: [
+		{
+			object: 'a',
+			methods: [
+				{
+					label: 'b',
+					insertText: 'b(${1:name})',
+					detail: 'String b(String name)',
+					documentation: '全局对象 a 的方法',
+					snippet: true
+				}
+			],
+			properties: [
+				{
+					label: 'version',
+					detail: 'String version'
+				}
+			]
+		}
+	],
+	javascript: [
+		{
+			object: 'Bpmtools',
+			methods: [
+				{
+					label: 'b',
+					insertText: 'b()',
+					detail: 'function b(): void',
+					documentation: '全局对象 a 的方法',
+					snippet: true
+				}
+			]
+		}
+	]
+}
+
 const props = defineProps<{
 	data: ModuleInvokeData
 }>()

+ 4 - 4
apps/web/src/views/editor/Editor.vue

@@ -17,16 +17,16 @@
 					</el-tag>
 				</div>
 
-				<el-input-tag
+				<!-- <el-input-tag
 					v-show="showTagInput"
 					v-model="workflow.tags"
 					:placeholder="t('pages.editor.tagPlaceholder')"
 					:aria-label="t('pages.editor.tagPlaceholder')"
 					:max="5"
 					@blur="showTagInput = false"
-				/>
+				/> -->
 
-				<IconButton
+				<!-- <IconButton
 					v-if="!workflow.tags?.length && !showTagInput"
 					icon="iconoir:plus"
 					type="primary"
@@ -34,7 +34,7 @@
 					@click="showTagInput = true"
 				>
 					{{ t('pages.editor.tagButton') }}
-				</IconButton>
+				</IconButton> -->
 			</div>
 
 			<div class="right flex items-center gap-2">

+ 1 - 1
apps/web/vite.config.ts

@@ -30,7 +30,7 @@ export default defineConfig(({ mode }) => {
 			}),
 			// 代码编辑器
 			(monacoEditorPlugin as any).default({
-				languageWorkers: ['editorWorkerService', 'typescript', 'json', 'html', 'css']
+				languageWorkers: ['editorWorkerService', 'typescript', 'json', 'html', 'css', 'java']
 			}),
 			// 按需求加载(模板)
 			AutoImport({