|
|
@@ -0,0 +1,426 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
|
+import { MenuOption } from 'lexical-vue'
|
|
|
+import { useLexicalComposer } from 'lexical-vue/LexicalComposer'
|
|
|
+import { TypeaheadMenuPlugin } from 'lexical-vue/LexicalTypeaheadMenuPlugin'
|
|
|
+import type { TriggerFn } from 'lexical-vue/LexicalTypeaheadMenuPlugin'
|
|
|
+import { $insertNodes, type LexicalEditor, type TextNode } from 'lexical'
|
|
|
+import { Icon } from '@repo/ui'
|
|
|
+import { VARIABLE_TYPE_OPTIONS } from '@/constant'
|
|
|
+import { nodeMap } from '@/nodes'
|
|
|
+import { $createVarLabelNode } from './VarLabel'
|
|
|
+import { getVariableTypeByValue, type VariableType } from '../utils'
|
|
|
+
|
|
|
+type NodeMapItem = {
|
|
|
+ icon?: string
|
|
|
+ iconColor?: string
|
|
|
+}
|
|
|
+
|
|
|
+const NODE_MAP = nodeMap as Record<string, NodeMapItem>
|
|
|
+
|
|
|
+export type PromptEditorMenuOption = {
|
|
|
+ key: string
|
|
|
+ label: string
|
|
|
+ value: string
|
|
|
+ className?: string
|
|
|
+ type?: VariableType
|
|
|
+ groupId?: string
|
|
|
+ groupName?: string
|
|
|
+ nodeType?: string
|
|
|
+ valueType?: string
|
|
|
+}
|
|
|
+
|
|
|
+class VarMenuOption extends MenuOption {
|
|
|
+ label: string
|
|
|
+ value: string
|
|
|
+ className?: string
|
|
|
+ type?: VariableType
|
|
|
+ groupId?: string
|
|
|
+ groupName?: string
|
|
|
+ nodeType?: string
|
|
|
+ valueType?: string
|
|
|
+
|
|
|
+ constructor(option: PromptEditorMenuOption, index: number) {
|
|
|
+ super(`${option.key || option.value}-${index}`)
|
|
|
+ this.label = option.label
|
|
|
+ this.value = option.value
|
|
|
+ this.className = option.className
|
|
|
+ this.type = option.type
|
|
|
+ this.groupId = option.groupId
|
|
|
+ this.groupName = option.groupName
|
|
|
+ this.nodeType = option.nodeType
|
|
|
+ this.valueType = option.valueType
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type MenuSection = {
|
|
|
+ key: string
|
|
|
+ title: string
|
|
|
+ items: Array<{
|
|
|
+ index: number
|
|
|
+ option: VarMenuOption
|
|
|
+ }>
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ triggerStrings?: string[]
|
|
|
+ options: PromptEditorMenuOption[]
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ triggerStrings: () => ['/']
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+const editor = useLexicalComposer()
|
|
|
+const keyword = ref('')
|
|
|
+const isMenuOpen = ref(false)
|
|
|
+const suppressMenu = ref(false)
|
|
|
+const menuPanelRef = ref<HTMLElement | null>(null)
|
|
|
+
|
|
|
+const normalizedOptions = computed(() => {
|
|
|
+ return props.options.map((option, index) => new VarMenuOption(option, index))
|
|
|
+})
|
|
|
+
|
|
|
+const filteredOptions = computed(() => {
|
|
|
+ const kw = keyword.value.trim().toLowerCase()
|
|
|
+ if (!kw) return normalizedOptions.value
|
|
|
+
|
|
|
+ return normalizedOptions.value.filter((option) => {
|
|
|
+ const label = option.label.toLowerCase()
|
|
|
+ const value = option.value.toLowerCase()
|
|
|
+ const groupName = (option.groupName || '').toLowerCase()
|
|
|
+ return label.includes(kw) || value.includes(kw) || groupName.includes(kw)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+const groupedSections = computed<MenuSection[]>(() => {
|
|
|
+ const map = new Map<string, MenuSection>()
|
|
|
+
|
|
|
+ filteredOptions.value.forEach((option, index) => {
|
|
|
+ const key = option.groupId || 'custom'
|
|
|
+ const title = option.groupName || 'Custom Variables'
|
|
|
+
|
|
|
+ if (!map.has(key)) {
|
|
|
+ map.set(key, { key, title, items: [] })
|
|
|
+ }
|
|
|
+
|
|
|
+ map.get(key)?.items.push({ index, option })
|
|
|
+ })
|
|
|
+
|
|
|
+ return [...map.values()]
|
|
|
+})
|
|
|
+
|
|
|
+const normalizedTriggers = computed(() => {
|
|
|
+ return props.triggerStrings
|
|
|
+ .map((trigger) => trigger.trim())
|
|
|
+ .filter((trigger) => trigger.length > 0)
|
|
|
+})
|
|
|
+
|
|
|
+const hasFreshTriggerAtTail = (text: string) => {
|
|
|
+ return normalizedTriggers.value.some((trigger) => text.endsWith(trigger))
|
|
|
+}
|
|
|
+
|
|
|
+const triggerMatch: TriggerFn = (text, _lexicalEditor: LexicalEditor) => {
|
|
|
+ if (suppressMenu.value) {
|
|
|
+ if (!hasFreshTriggerAtTail(text)) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ suppressMenu.value = false
|
|
|
+ }
|
|
|
+
|
|
|
+ let latestMatch: ReturnType<TriggerFn> = null
|
|
|
+
|
|
|
+ for (const trigger of normalizedTriggers.value) {
|
|
|
+ const triggerIndex = text.lastIndexOf(trigger)
|
|
|
+ if (triggerIndex < 0) continue
|
|
|
+
|
|
|
+ const matchingString = text.slice(triggerIndex + trigger.length)
|
|
|
+ if (/\s/.test(matchingString)) continue
|
|
|
+
|
|
|
+ const match = {
|
|
|
+ leadOffset: triggerIndex,
|
|
|
+ matchingString,
|
|
|
+ replaceableString: `${trigger}${matchingString}`
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!latestMatch || triggerIndex > latestMatch.leadOffset) {
|
|
|
+ latestMatch = match
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return latestMatch
|
|
|
+}
|
|
|
+
|
|
|
+const normalizeTypeLabel = (type?: string) => {
|
|
|
+ if (!type) return ''
|
|
|
+ return VARIABLE_TYPE_OPTIONS.find((item) => item.value === type)?.label || type
|
|
|
+}
|
|
|
+
|
|
|
+const onQueryChange = (payload: string | null) => {
|
|
|
+ const nextKeyword = payload || ''
|
|
|
+ if (nextKeyword.length > 0) {
|
|
|
+ requestCloseMenu()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ keyword.value = nextKeyword
|
|
|
+}
|
|
|
+
|
|
|
+const onOpen = () => {
|
|
|
+ isMenuOpen.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const onClose = () => {
|
|
|
+ isMenuOpen.value = false
|
|
|
+ keyword.value = ''
|
|
|
+}
|
|
|
+
|
|
|
+const requestCloseMenu = () => {
|
|
|
+ suppressMenu.value = true
|
|
|
+ keyword.value = ''
|
|
|
+ editor.update(() => {
|
|
|
+ // Trigger plugin update cycle so menu closes immediately.
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const resolveOptionClassName = (option: VarMenuOption) => {
|
|
|
+ if (option.className) return option.className
|
|
|
+ if (option.type) return `${option.type}-label`
|
|
|
+
|
|
|
+ const variableType = getVariableTypeByValue(option.value)
|
|
|
+ if (variableType) return `${variableType}-label`
|
|
|
+
|
|
|
+ return 'custom-label'
|
|
|
+}
|
|
|
+
|
|
|
+const onSelectOption = (payload: {
|
|
|
+ option: VarMenuOption
|
|
|
+ textNodeContainingQuery: TextNode | null
|
|
|
+ closeMenu: () => void
|
|
|
+ matchingString: string
|
|
|
+}) => {
|
|
|
+ const { option, textNodeContainingQuery, closeMenu } = payload
|
|
|
+
|
|
|
+ editor.update(() => {
|
|
|
+ const node = $createVarLabelNode(resolveOptionClassName(option), option.value)
|
|
|
+
|
|
|
+ if (textNodeContainingQuery) {
|
|
|
+ textNodeContainingQuery.replace(node)
|
|
|
+ } else {
|
|
|
+ $insertNodes([node])
|
|
|
+ }
|
|
|
+
|
|
|
+ closeMenu()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const onDocumentMouseDown = (event: MouseEvent) => {
|
|
|
+ if (!isMenuOpen.value) return
|
|
|
+
|
|
|
+ const target = event.target as Node | null
|
|
|
+ if (!target) return
|
|
|
+
|
|
|
+ const clickedInsideMenu = menuPanelRef.value?.contains(target) ?? false
|
|
|
+ if (clickedInsideMenu) return
|
|
|
+
|
|
|
+ const rootElement = editor.getRootElement()
|
|
|
+ const clickedInsideEditor = rootElement?.contains(target) ?? false
|
|
|
+ if (clickedInsideEditor) return
|
|
|
+
|
|
|
+ requestCloseMenu()
|
|
|
+ rootElement?.blur()
|
|
|
+}
|
|
|
+
|
|
|
+const onEditorKeyDown = (event: KeyboardEvent) => {
|
|
|
+ if (!isMenuOpen.value) return
|
|
|
+ if (event.key !== 'Escape') return
|
|
|
+
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ requestCloseMenu()
|
|
|
+}
|
|
|
+
|
|
|
+let removeRootListener: (() => void) | null = null
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ document.addEventListener('mousedown', onDocumentMouseDown, true)
|
|
|
+
|
|
|
+ removeRootListener = editor.registerRootListener((nextRoot, prevRoot) => {
|
|
|
+ prevRoot?.removeEventListener('keydown', onEditorKeyDown, true)
|
|
|
+ nextRoot?.addEventListener('keydown', onEditorKeyDown, true)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ document.removeEventListener('mousedown', onDocumentMouseDown, true)
|
|
|
+ removeRootListener?.()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <TypeaheadMenuPlugin
|
|
|
+ :options="filteredOptions"
|
|
|
+ :onOpen="onOpen"
|
|
|
+ :onClose="onClose"
|
|
|
+ :onQueryChange="onQueryChange"
|
|
|
+ :onSelectOption="onSelectOption"
|
|
|
+ :triggerFn="triggerMatch"
|
|
|
+ :ignoreEntityBoundary="true"
|
|
|
+ anchorClassName="z-[999999] translate-y-[6px]"
|
|
|
+ >
|
|
|
+ <template #default="{ anchorElementRef, itemProps }">
|
|
|
+ <Teleport v-if="anchorElementRef" :to="anchorElementRef">
|
|
|
+ <div ref="menuPanelRef" class="var-select__popover">
|
|
|
+ <div class="var-select__panel">
|
|
|
+ <el-input
|
|
|
+ :model-value="keyword"
|
|
|
+ size="small"
|
|
|
+ placeholder="搜索变量"
|
|
|
+ class="var-select__search"
|
|
|
+ readonly
|
|
|
+ tabindex="-1"
|
|
|
+ @mousedown.prevent
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="var-select__list">
|
|
|
+ <div v-for="section in groupedSections" :key="section.key" class="var-select__group">
|
|
|
+ <div class="var-select__group-title">{{ section.title }}</div>
|
|
|
+ <div
|
|
|
+ v-for="item in section.items"
|
|
|
+ :key="item.option.key"
|
|
|
+ class="var-select__item"
|
|
|
+ :class="{ 'is-active': itemProps.selectedIndex === item.index }"
|
|
|
+ :ref="item.option.setRefElement"
|
|
|
+ @mouseenter="itemProps.setHighlightedIndex(item.index)"
|
|
|
+ @mousedown.prevent
|
|
|
+ @click="itemProps.selectOptionAndCleanUp(item.option)"
|
|
|
+ >
|
|
|
+ <span
|
|
|
+ class="var-select__item-prefix"
|
|
|
+ :class="item.option.groupId"
|
|
|
+ :style="{ background: NODE_MAP[item.option.nodeType || '']?.iconColor || '' }"
|
|
|
+ >
|
|
|
+ <span v-if="item.option.groupId === 'env'" class="text-6px">ENV</span>
|
|
|
+ <span v-else-if="item.option.groupId === 'sys'" class="text-6px">SYS</span>
|
|
|
+ <Icon
|
|
|
+ v-else-if="item.option.nodeType && NODE_MAP[item.option.nodeType || '']?.icon"
|
|
|
+ :icon="NODE_MAP[item.option.nodeType || '']?.icon || ''"
|
|
|
+ :size="16"
|
|
|
+ />
|
|
|
+ <span v-else class="text-6px">VAR</span>
|
|
|
+ </span>
|
|
|
+ <span class="var-select__item-name" :title="item.option.label">
|
|
|
+ {{ item.option.label }}
|
|
|
+ </span>
|
|
|
+ <span class="var-select__item-type">
|
|
|
+ {{ normalizeTypeLabel(item.option.valueType) }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="!groupedSections.length" class="var-select__empty">暂无匹配变量</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Teleport>
|
|
|
+ </template>
|
|
|
+ </TypeaheadMenuPlugin>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped lang="less">
|
|
|
+.var-select__popover {
|
|
|
+ min-width: 320px;
|
|
|
+ max-width: 420px;
|
|
|
+ padding: 8px;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #fff;
|
|
|
+ box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__panel {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__search {
|
|
|
+ :deep(.el-input__wrapper) {
|
|
|
+ box-shadow: none;
|
|
|
+ border-radius: 8px;
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__list {
|
|
|
+ max-height: 260px;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__group + .var-select__group {
|
|
|
+ margin-top: 8px;
|
|
|
+ border-top: 1px solid #f2f2f2;
|
|
|
+ padding-top: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__group-title {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 4px 6px;
|
|
|
+ border-radius: 6px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #333;
|
|
|
+
|
|
|
+ &.is-active,
|
|
|
+ &:hover {
|
|
|
+ background-color: #f5f7ff;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__item-prefix {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ border-radius: 6px;
|
|
|
+ color: #fff;
|
|
|
+ flex-shrink: 0;
|
|
|
+
|
|
|
+ &.env {
|
|
|
+ background: #6366f1;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.sys {
|
|
|
+ background: #f97316;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__item-name {
|
|
|
+ flex: 1;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__item-type {
|
|
|
+ color: #999;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.var-select__empty {
|
|
|
+ padding: 12px 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+</style>
|