Prechádzať zdrojové kódy

fix: 修改变量标签展示问题

jiaxing.liao 2 týždňov pred
rodič
commit
f3ff7c4478

+ 2 - 1
apps/web/package.json

@@ -15,6 +15,7 @@
     "axios": "^1.13.2",
     "echarts": "^6.0.0",
     "element-plus": "^2.13.1",
+    "js-yaml": "^4.1.1",
     "lexical": "0.38.1",
     "lexical-vue": "^0.14.1",
     "lodash-es": "^4.17.21",
@@ -30,10 +31,10 @@
     "vue-router": "4"
   },
   "devDependencies": {
+    "@repo/api-client": "workspace:*",
     "@repo/api-service": "workspace:*",
     "@repo/ui": "workspace:*",
     "@repo/workflow": "workspace:*",
-    "@repo/api-client": "workspace:*",
     "@types/lodash-es": "^4.17.12",
     "@types/nprogress": "^0.2.3",
     "@vitejs/plugin-vue": "^6.0.1",

+ 2 - 2
apps/web/src/components/VarLabel/index.vue

@@ -53,7 +53,6 @@ const valueInfo = computed(() => {
 	} else {
 		// 节点变量,需要解析节点类型名称
 		const availableNodeVars = props.nodeVars ?? injectedNodeVars?.value
-
 		if (availableNodeVars?.length) {
 			const node = availableNodeVars.find((item) => item.id === prefix)
 
@@ -109,7 +108,7 @@ const valueInfo = computed(() => {
 			{{ valueInfo.nodeName }}
 			{{ valueInfo.nodeName ? '/' : '' }}
 			<span class="text-gray-600">{{ valueInfo.value }}</span>
-			<Icon icon="lucide:circle-alert" color="red" />
+			<Icon v-if="!valueInfo.nodeName" icon="lucide:circle-alert" color="red" />
 		</div>
 	</div>
 </template>
@@ -132,6 +131,7 @@ const valueInfo = computed(() => {
 	padding: 0 4px;
 	line-height: 1;
 	display: inline-flex;
+	margin-right: 4px;
 }
 
 .var-select__item-prefix {

+ 160 - 97
apps/web/src/nodes/_base/PromptEditor/plugins/VarLabel.tsx

@@ -1,27 +1,108 @@
-import { createApp, h, reactive, shallowRef } from 'vue'
-import { TextNode } from 'lexical'
-import type { App, ShallowRef } from 'vue'
-import type { EditorConfig, SerializedTextNode } from 'lexical'
+import { computed, defineComponent, h, onMounted, onUnmounted, ref, shallowRef } from 'vue'
+import { $applyNodeReplacement } from 'lexical'
 import VarLabel from '@/components/VarLabel/index.vue'
 import type { NodeVar } from '@/types/var'
-
-export type SerializedVarLabelNode = SerializedTextNode & {
-	type: 'var-label'
-	className: string
-	key?: string
+import { DecoratorBlockNode } from 'lexical-vue/LexicalDecoratorBlockNode'
+import type { Component, PropType, ShallowRef } from 'vue'
+import type {
+	DOMConversionMap,
+	DOMConversionOutput,
+	DOMExportOutput,
+	EditorConfig,
+	ElementFormatType,
+	LexicalEditor,
+	LexicalNode,
+	NodeKey,
+	Spread
+} from 'lexical'
+import type { SerializedDecoratorBlockNode } from 'lexical-vue'
+
+export type SerializedVarLabelNode = Spread<
+	{
+		className: string
+		text: string
+	},
+	SerializedDecoratorBlockNode
+>
+
+type LegacySerializedVarLabelNode = {
+	className?: unknown
+	text?: unknown
+	format?: unknown
 }
 
-type MountedVarLabelState = {
-	app: App<Element>
+const DATA_ATTR_CONTEXT_ID = 'data-prompt-editor-context-id'
+
+const emptyNodeVars: NodeVar[] = []
+const promptEditorNodeVarsRefs = new Map<string, ShallowRef<NodeVar[]>>()
+
+const VarLabelDecorator = defineComponent({
+	name: 'PromptEditorVarLabelDecorator',
 	props: {
-		label: string
-		contextId: string
+		label: {
+			type: String,
+			required: true
+		},
+		editor: {
+			type: Object as PropType<LexicalEditor>,
+			required: true
+		}
+	},
+	setup(props) {
+		const contextId = ref('')
+
+		const syncContextId = (attempt = 0) => {
+			const nextContextId =
+				props.editor
+					.getRootElement()
+					?.closest<HTMLElement>(`[${DATA_ATTR_CONTEXT_ID}]`)
+					?.getAttribute(DATA_ATTR_CONTEXT_ID) || ''
+
+			if (contextId.value !== nextContextId) {
+				contextId.value = nextContextId
+			}
+
+			if (!nextContextId && attempt < 6) {
+				requestAnimationFrame(() => {
+					syncContextId(attempt + 1)
+				})
+			}
+		}
+
+		let removeRootListener: (() => void) | null = null
+
+		onMounted(() => {
+			syncContextId()
+			removeRootListener = props.editor.registerRootListener(() => {
+				syncContextId()
+			})
+		})
+
+		onUnmounted(() => {
+			removeRootListener?.()
+		})
+
+		const nodeVars = computed(() => {
+			if (!contextId.value) return emptyNodeVars
+			return getNodeVarsRef(contextId.value).value
+		})
+
+		return () =>
+			h(VarLabel, {
+				label: props.label,
+				nodeVars: nodeVars.value
+			})
 	}
-}
+})
 
-const mountedVarLabels = new WeakMap<HTMLElement, MountedVarLabelState>()
-const promptEditorNodeVarsRefs = new Map<string, ShallowRef<NodeVar[]>>()
-const emptyNodeVars: NodeVar[] = []
+function convertVarLabelElement(domNode: HTMLElement): null | DOMConversionOutput {
+	const text = domNode.getAttribute('data-lexical-var-label')
+	if (!text) return null
+
+	const className = domNode.getAttribute('data-lexical-var-label-class') || 'custom-label'
+	const node = $createVarLabelNode(className, text)
+	return { node }
+}
 
 function getNodeVarsRef(contextId: string) {
 	const existingRef = promptEditorNodeVarsRefs.get(contextId)
@@ -32,28 +113,6 @@ function getNodeVarsRef(contextId: string) {
 	return nextRef
 }
 
-function syncMountedContextId(dom: HTMLElement, props: MountedVarLabelState['props'], attempt = 0) {
-	queueMicrotask(() => {
-		if (!dom.isConnected) {
-			if (attempt < 10) {
-				requestAnimationFrame(() => {
-					syncMountedContextId(dom, props, attempt + 1)
-				})
-			}
-			return
-		}
-
-		const nextContextId =
-			dom
-				.closest<HTMLElement>('[data-prompt-editor-context-id]')
-				?.getAttribute('data-prompt-editor-context-id') || ''
-
-		if (props.contextId !== nextContextId) {
-			props.contextId = nextContextId
-		}
-	})
-}
-
 export function syncPromptEditorVarLabelNodeVars(
 	contextId: string,
 	nodeVars: NodeVar[] | undefined
@@ -69,70 +128,37 @@ export function syncPromptEditorVarLabelNodeVars(
 	getNodeVarsRef(contextId).value = nextNodeVars
 }
 
-function renderLabel(dom: HTMLElement, className: string, rawText: string) {
-	dom.className = `${className} var-label-token`
-	dom.contentEditable = 'false'
-	dom.removeAttribute('title')
-
-	const mountedState = mountedVarLabels.get(dom)
-	if (mountedState) {
-		mountedState.props.label = rawText
-		syncMountedContextId(dom, mountedState.props)
-		return
-	}
-
-	const mountPoint = document.createElement('span')
-	dom.replaceChildren(mountPoint)
-
-	const props = reactive({
-		label: rawText,
-		contextId: ''
-	})
-
-	const app = createApp({
-		render() {
-			const nodeVars = props.contextId ? getNodeVarsRef(props.contextId).value : emptyNodeVars
-
-			return h(VarLabel, {
-				label: props.label,
-				nodeVars
-			})
-		}
-	})
-
-	app.mount(mountPoint)
-	syncMountedContextId(dom, props)
-
-	mountedVarLabels.set(dom, {
-		app,
-		props
-	})
-}
-
-export class VarLabelNode extends TextNode {
+export class VarLabelNode extends DecoratorBlockNode {
 	__className: string
+	__text: string
 
 	static getType() {
 		return 'var-label'
 	}
 
 	static clone(node: VarLabelNode) {
-		return new VarLabelNode(node.__className, node.__text, node.__key)
+		return new VarLabelNode(node.__className, node.__text, node.__format, node.__key)
 	}
 
-	static importJSON(serializedNode: SerializedTextNode): VarLabelNode {
-		const { className, text, key } = serializedNode as SerializedVarLabelNode
-		const node = new VarLabelNode(className, text, key)
-		node.setMode('token')
-		return node
+	static importJSON(serializedNode: SerializedVarLabelNode): VarLabelNode {
+		const legacyNode = serializedNode as SerializedVarLabelNode & LegacySerializedVarLabelNode
+		const className =
+			typeof legacyNode.className === 'string' && legacyNode.className.length
+				? legacyNode.className
+				: 'custom-label'
+		const text = typeof legacyNode.text === 'string' ? legacyNode.text : ''
+		const format: ElementFormatType = typeof legacyNode.format === 'string' ? legacyNode.format : ''
+
+		return new VarLabelNode(className, text, format)
 	}
 
-	constructor(className: string, text: string, key?: string) {
-		super(text, key)
+	constructor(className: string, text: string, format?: ElementFormatType, key?: NodeKey) {
+		super(format, key)
 		this.__className = className
+		this.__text = text
 	}
 
-	isTextEntity() {
+	isInline() {
 		return true
 	}
 
@@ -144,36 +170,73 @@ export class VarLabelNode extends TextNode {
 		return false
 	}
 
+	isKeyboardSelectable() {
+		return false
+	}
+
 	createDOM(_config: EditorConfig) {
 		const dom = document.createElement('span')
-		renderLabel(dom, this.__className, this.__text)
+		dom.className = `${this.__className} var-label-token`
+		dom.contentEditable = 'false'
 		return dom
 	}
 
-	updateDOM(prevNode: VarLabelNode, dom: ChildNode, _config: EditorConfig) {
-		if (!(dom instanceof HTMLElement)) return true
-
-		if (prevNode.__className !== this.__className || prevNode.__text !== this.__text) {
-			renderLabel(dom, this.__className, this.__text)
+	updateDOM(prevNode: VarLabelNode, dom: HTMLElement, _config: EditorConfig) {
+		if (prevNode.__className !== this.__className) {
+			dom.className = `${this.__className} var-label-token`
 		}
+		dom.contentEditable = 'false'
 
 		return false
 	}
 
+	static importDOM(): DOMConversionMap | null {
+		return {
+			span: (domNode: HTMLElement) => {
+				if (!domNode.hasAttribute('data-lexical-var-label')) return null
+
+				return {
+					conversion: convertVarLabelElement,
+					priority: 1
+				}
+			}
+		}
+	}
+
+	exportDOM(): DOMExportOutput {
+		const element = document.createElement('span')
+		element.setAttribute('data-lexical-var-label', this.__text)
+		element.setAttribute('data-lexical-var-label-class', this.__className)
+		element.textContent = this.__text
+		return { element }
+	}
+
+	getTextContent() {
+		return this.__text
+	}
+
+	decorate(_editor: LexicalEditor, _config: EditorConfig): Component {
+		return h(VarLabelDecorator, {
+			label: this.__text,
+			editor: _editor
+		})
+	}
+
 	exportJSON(): SerializedVarLabelNode {
 		return {
 			...super.exportJSON(),
 			type: 'var-label',
 			className: this.__className,
+			text: this.__text,
 			version: 1
 		}
 	}
 }
 
-export function $isVarLabelNode(node: unknown): node is VarLabelNode {
+export function $isVarLabelNode(node: LexicalNode | null | undefined): node is VarLabelNode {
 	return node instanceof VarLabelNode
 }
 
 export function $createVarLabelNode(className: string, text: string) {
-	return new VarLabelNode(className, text).setMode('token')
+	return $applyNodeReplacement(new VarLabelNode(className, text))
 }

+ 24 - 5
apps/web/src/nodes/_base/PromptEditor/plugins/VarLabelBlock.vue

@@ -1,10 +1,9 @@
 <script setup lang="ts">
-import { $createVarLabelNode } from './VarLabel'
-import { $createTextNode, TextNode } from 'lexical'
+import { $createVarLabelNode, $isVarLabelNode, VarLabelNode } from './VarLabel'
+import { $createTextNode, $nodesOfType, type LexicalNode, TextNode } from 'lexical'
 import { onMounted, onUnmounted } from 'vue'
 import { useLexicalComposer } from 'lexical-vue'
 import { splitTextByVariableSegments } from '../utils'
-import { $isVarLabelNode } from './VarLabel'
 
 function varLabelTransform(node: TextNode) {
 	if ($isVarLabelNode(node)) return
@@ -22,7 +21,7 @@ function varLabelTransform(node: TextNode) {
 			if (!segment.text.length) return null
 			return $createTextNode(segment.text)
 		})
-		.filter((segmentNode): segmentNode is TextNode => segmentNode !== null)
+		.filter((segmentNode): segmentNode is LexicalNode => segmentNode !== null)
 
 	if (!nodes.length) return
 
@@ -30,7 +29,7 @@ function varLabelTransform(node: TextNode) {
 	if (!firstNode) return
 
 	node.replace(firstNode)
-	let currentNode: TextNode = firstNode
+	let currentNode: LexicalNode = firstNode
 	for (const segmentNode of restNodes) {
 		currentNode.insertAfter(segmentNode)
 		currentNode = segmentNode
@@ -41,8 +40,28 @@ const editor = useLexicalComposer()
 
 onMounted(() => {
 	const removeTransform = editor.registerNodeTransform(TextNode, varLabelTransform)
+	const refreshVarLabelDecorators = () => {
+		editor.update(() => {
+			const varLabelNodes = $nodesOfType(VarLabelNode)
+			for (const varLabelNode of varLabelNodes) {
+				varLabelNode.markDirty()
+			}
+		})
+	}
+
+	const removeRootListener = editor.registerRootListener((nextRoot) => {
+		if (!nextRoot) return
+		requestAnimationFrame(() => {
+			refreshVarLabelDecorators()
+		})
+	})
+
+	requestAnimationFrame(() => {
+		refreshVarLabelDecorators()
+	})
 
 	onUnmounted(() => {
+		removeRootListener()
 		removeTransform()
 	})
 })

+ 1 - 4
apps/web/src/nodes/_base/PromptEditor/utils.ts

@@ -25,11 +25,8 @@ export function textToEditorState(text: string) {
 						}
 						return {
 							className: `${segment.varType}-label`,
-							detail: 0,
-							format: 0,
-							mode: 'token',
-							style: '',
 							text: segment.text,
+							format: '',
 							type: 'var-label',
 							version: 1
 						}

+ 3 - 0
pnpm-lock.yaml

@@ -154,6 +154,9 @@ importers:
       element-plus:
         specifier: ^2.13.1
         version: 2.13.6(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
+      js-yaml:
+        specifier: ^4.1.1
+        version: 4.1.1
       lexical:
         specifier: 0.38.1
         version: 0.38.1