diff --git a/packages/ui/src/components/variable-extraction/VariableAwareInput.vue b/packages/ui/src/components/variable-extraction/VariableAwareInput.vue index d7a300b0..40a940e1 100644 --- a/packages/ui/src/components/variable-extraction/VariableAwareInput.vue +++ b/packages/ui/src/components/variable-extraction/VariableAwareInput.vue @@ -57,6 +57,7 @@ import { variableHighlighter, variableAutocompletion, missingVariableTooltip, + existingVariableTooltip, createThemeExtension, type VariableDetectionLabels, } from "./codemirror-extensions"; @@ -132,6 +133,7 @@ let editorView: EditorView | null = null; const autocompletionCompartment = new Compartment(); const highlighterCompartment = new Compartment(); const missingVariableTooltipCompartment = new Compartment(); +const existingVariableTooltipCompartment = new Compartment(); const placeholderCompartment = new Compartment(); const themeCompartment = new Compartment(); @@ -503,6 +505,23 @@ onMounted(() => { }, ), ), + // 已存在变量提示 + existingVariableTooltipCompartment.of( + existingVariableTooltip( + variableDetectionLabels.value, + { + backgroundColor: themeVars.value.cardColor, + borderColor: themeVars.value.borderColor, + borderRadius: themeVars.value.borderRadius, + textColor: themeVars.value.textColor2, + shadow: themeVars.value.boxShadow2, + sourceGlobalColor: themeVars.value.infoColor, + sourceTemporaryColor: themeVars.value.successColor, + sourcePredefinedColor: themeVars.value.warningColor, + surfaceOverlay: themeVars.value.popoverColor, + }, + ), + ), // 主题适配 themeCompartment.of(createThemeExtension(themeVars.value)), // 监听文档变化 @@ -586,6 +605,22 @@ watch( }, ), ), + existingVariableTooltipCompartment.reconfigure( + existingVariableTooltip( + variableDetectionLabels.value, + { + backgroundColor: themeVars.value.cardColor, + borderColor: themeVars.value.borderColor, + borderRadius: themeVars.value.borderRadius, + textColor: themeVars.value.textColor2, + shadow: themeVars.value.boxShadow2, + sourceGlobalColor: themeVars.value.infoColor, + sourceTemporaryColor: themeVars.value.successColor, + sourcePredefinedColor: themeVars.value.warningColor, + surfaceOverlay: themeVars.value.popoverColor, + }, + ), + ), ], }); }, @@ -618,6 +653,36 @@ watch( editorView.dispatch({ effects: [ themeCompartment.reconfigure(createThemeExtension(vars)), + missingVariableTooltipCompartment.reconfigure( + missingVariableTooltip( + handleAddMissingVariable, + variableDetectionLabels.value, + { + backgroundColor: vars.cardColor, + borderColor: vars.borderColor, + borderRadius: vars.borderRadius, + textColor: vars.textColor2, + primaryColor: vars.primaryColor, + primaryColorHover: vars.primaryColorHover, + }, + ), + ), + existingVariableTooltipCompartment.reconfigure( + existingVariableTooltip( + variableDetectionLabels.value, + { + backgroundColor: vars.cardColor, + borderColor: vars.borderColor, + borderRadius: vars.borderRadius, + textColor: vars.textColor2, + shadow: vars.boxShadow2, + sourceGlobalColor: vars.infoColor, + sourceTemporaryColor: vars.successColor, + sourcePredefinedColor: vars.warningColor, + surfaceOverlay: vars.popoverColor, + }, + ), + ), ], }); }, diff --git a/packages/ui/src/components/variable-extraction/codemirror-extensions.ts b/packages/ui/src/components/variable-extraction/codemirror-extensions.ts index 7c28938e..b80dc429 100644 --- a/packages/ui/src/components/variable-extraction/codemirror-extensions.ts +++ b/packages/ui/src/components/variable-extraction/codemirror-extensions.ts @@ -30,6 +30,115 @@ export interface MissingVariableTooltipTheme { primaryColorHover?: string } +export interface ExistingVariableTooltipTheme extends MissingVariableTooltipTheme { + shadow?: string + sourceGlobalColor?: string + sourceTemporaryColor?: string + sourcePredefinedColor?: string + surfaceOverlay?: string +} + +type RgbTuple = { + r: number + g: number + b: number +} + +function parseColor(color: string | undefined | null): RgbTuple | null { + if (!color) return null + + const normalized = color.trim().toLowerCase() + if (normalized.startsWith('#')) { + const hex = normalized.slice(1) + if (hex.length === 3) { + return { + r: Number.parseInt(hex[0] + hex[0], 16), + g: Number.parseInt(hex[1] + hex[1], 16), + b: Number.parseInt(hex[2] + hex[2], 16) + } + } + if (hex.length === 6 || hex.length === 8) { + return { + r: Number.parseInt(hex.slice(0, 2), 16), + g: Number.parseInt(hex.slice(2, 4), 16), + b: Number.parseInt(hex.slice(4, 6), 16) + } + } + return null + } + + const rgbMatch = normalized.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/) + if (rgbMatch) { + return { + r: Number.parseInt(rgbMatch[1], 10), + g: Number.parseInt(rgbMatch[2], 10), + b: Number.parseInt(rgbMatch[3], 10) + } + } + + return null +} + +function toHex({ r, g, b }: RgbTuple): string { + const clamp = (value: number) => Math.max(0, Math.min(255, Math.round(value))) + return `#${clamp(r).toString(16).padStart(2, '0')}${clamp(g).toString(16).padStart(2, '0')}${clamp(b) + .toString(16) + .padStart(2, '0')}` +} + +function mixColors(source: RgbTuple, target: RgbTuple, ratio: number): RgbTuple { + const resolved = Math.max(0, Math.min(1, ratio)) + return { + r: source.r + (target.r - source.r) * resolved, + g: source.g + (target.g - source.g) * resolved, + b: source.b + (target.b - source.b) * resolved + } +} + +function withAlpha(color: string | undefined, alpha: number, fallback: string): string { + const parsed = parseColor(color) + if (!parsed) return fallback + const resolvedAlpha = Math.max(0, Math.min(1, alpha)) + return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${resolvedAlpha})` +} + +function getLuminance({ r, g, b }: RgbTuple): number { + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +function getReadableTextColor( + baseColor: string | undefined, + fallbackColor: string, + isDark: boolean +): string { + const parsedBase = parseColor(baseColor) + if (!parsedBase) return fallbackColor + + const baseLuminance = getLuminance(parsedBase) + if (isDark && baseLuminance < 200) { + return toHex(mixColors(parsedBase, { r: 255, g: 255, b: 255 }, 0.4)) + } + if (!isDark && baseLuminance > 160) { + return toHex(mixColors(parsedBase, { r: 0, g: 0, b: 0 }, 0.3)) + } + + return toHex(parsedBase) +} + +function detectIsDarkTheme(themeVars: ThemeCommonVars): boolean { + const cardRgb = parseColor(themeVars.cardColor) + if (cardRgb) { + return getLuminance(cardRgb) < 128 + } + + const primaryText = parseColor(themeVars.textColor1) + if (primaryText) { + return getLuminance(primaryText) > 180 + } + + return false +} + /** 变量名允许的字符集合 (支持 Unicode 字母与数字与分隔符) */ const VARIABLE_CHAR_CLASS = '[\\p{L}\\p{N}_\\-.]' const VARIABLE_TRIGGER_REGEX = new RegExp(`\\{\\{${VARIABLE_CHAR_CLASS}*`, 'u') @@ -166,6 +275,160 @@ export function variableAutocompletion( }) } +/** + * 已存在变量悬浮提示扩展 + * + * 当鼠标悬停在已存在的变量上时,显示变量来源标签和变量值 + */ +export function existingVariableTooltip( + labels: VariableDetectionLabels, + theme: ExistingVariableTooltipTheme = {} +) { + return hoverTooltip((view, pos, _side) => { + const { node } = view.domAtPos(pos) + const element = node instanceof Element ? node : node.parentElement + + if (!element) return null + + const isExisting = + element.classList?.contains('cm-variable-global') || + element.classList?.contains('cm-variable-temporary') || + element.classList?.contains('cm-variable-predefined') || + element.parentElement?.classList?.contains('cm-variable-global') || + element.parentElement?.classList?.contains('cm-variable-temporary') || + element.parentElement?.classList?.contains('cm-variable-predefined') + + if (!isExisting) return null + + const targetElement = element.classList?.contains('cm-variable-global') || + element.classList?.contains('cm-variable-temporary') || + element.classList?.contains('cm-variable-predefined') + ? element + : element.parentElement + + if (!targetElement) return null + + const varName = targetElement.getAttribute('data-variable-name') + const varSource = targetElement.getAttribute('data-variable-source') + const varValue = targetElement.getAttribute('data-variable-value') || '' + + if (!varName || !varSource) return null + + const accentColorMap: Record = { + global: theme.sourceGlobalColor || '#2080f0', + temporary: theme.sourceTemporaryColor || '#18a058', + predefined: theme.sourcePredefinedColor || '#8a63d2' + } + + const accentColor = accentColorMap[varSource] || '#2080f0' + const surfaceOverlay = + theme.surfaceOverlay || withAlpha(theme.backgroundColor, 0.92, '#ffffff') + const shadow = theme.shadow || '0 12px 32px rgba(15, 23, 42, 0.18)' + + const text = view.state.doc.toString() + const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp(`\\{\\{${escapedVarName}\\}\\}`, 'gu') + let match + let varFrom = -1 + let varTo = -1 + + while ((match = regex.exec(text)) !== null) { + const matchFrom = match.index + const matchTo = matchFrom + match[0].length + if (pos >= matchFrom && pos <= matchTo) { + varFrom = matchFrom + varTo = matchTo + break + } + } + + if (varFrom === -1) return null + + return { + pos: varFrom, + end: varTo, + above: true, + create() { + const dom = document.createElement('div') + dom.className = 'cm-existing-variable-tooltip-container' + + const borderColor = theme.borderColor || withAlpha(accentColor, 0.18, '#dcdcdc') + const borderRadius = theme.borderRadius || '12px' + const textColor = theme.textColor || '#4c4f69' + + dom.style.padding = '12px 16px' + dom.style.background = surfaceOverlay + dom.style.border = `1px solid ${borderColor}` + dom.style.borderRadius = borderRadius + dom.style.boxShadow = shadow + dom.style.fontSize = '13px' + dom.style.color = textColor + dom.style.maxWidth = '420px' + dom.style.lineHeight = '1.6' + const sourceLabelText = + varSource === 'global' + ? labels.sourceGlobal + : varSource === 'temporary' + ? labels.sourceTemporary + : labels.sourcePredefined + + const sourceTag = document.createElement('span') + sourceTag.style.fontSize = '11px' + sourceTag.style.fontWeight = '600' + sourceTag.style.padding = '2px 8px' + sourceTag.style.borderRadius = '999px' + sourceTag.style.background = withAlpha(accentColor, 0.1, '#eaf4ff') + sourceTag.style.color = accentColor + sourceTag.style.border = `1px solid ${withAlpha(accentColor, 0.25, accentColor)}` + sourceTag.textContent = sourceLabelText + sourceTag.style.alignSelf = 'flex-start' + sourceTag.style.display = 'inline-flex' + sourceTag.style.alignItems = 'center' + dom.style.display = 'flex' + dom.style.flexDirection = 'column' + dom.style.gap = '8px' + dom.appendChild(sourceTag) + + const valueSection = document.createElement('div') + valueSection.style.display = 'flex' + valueSection.style.flexDirection = 'column' + valueSection.style.gap = '6px' + valueSection.style.padding = '10px 12px' + valueSection.style.background = withAlpha(accentColor, 0.08, 'rgba(0, 0, 0, 0.04)') + valueSection.style.borderRadius = '8px' + valueSection.style.border = `1px solid ${withAlpha(accentColor, 0.18, borderColor)}` + + const valueElement = document.createElement('div') + valueElement.style.fontSize = '13px' + valueElement.style.color = textColor + valueElement.style.fontWeight = '500' + valueElement.style.whiteSpace = 'pre-wrap' + valueElement.style.wordBreak = 'break-word' + valueElement.style.maxHeight = '180px' + valueElement.style.overflowY = 'auto' + valueElement.style.fontFamily = + 'var(--n-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace)' + + if (varValue.trim()) { + valueElement.style.fontStyle = 'normal' + valueElement.style.opacity = '1' + valueElement.textContent = varValue + } else { + valueElement.style.fontWeight = '400' + valueElement.style.fontStyle = 'italic' + valueElement.style.opacity = '0.7' + valueElement.textContent = labels.emptyValue + } + + valueSection.appendChild(valueElement) + dom.appendChild(valueSection) + + return { dom } + } + } + }) +} + /** * 缺失变量悬浮提示扩展 * @@ -280,8 +543,70 @@ export function missingVariableTooltip( /** * 主题扩展 - 适配 Naive UI 主题 + * 根据亮色/暗色主题动态调整变量高亮颜色,确保可读性 */ export function createThemeExtension(themeVars: ThemeCommonVars) { + const isDark = detectIsDarkTheme(themeVars) + + const fallbackSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)' + const fallbackText = themeVars.textColor1 || (isDark ? '#ffffff' : '#1f1f1f') + + const highlightColors = { + global: { + backgroundColor: withAlpha( + themeVars.infoColorSuppl ?? themeVars.infoColor ?? themeVars.primaryColor, + isDark ? 0.3 : 0.16, + fallbackSurface + ), + color: `${getReadableTextColor( + themeVars.infoColorSuppl ?? themeVars.infoColor ?? themeVars.primaryColor, + fallbackText, + isDark + )} !important` + }, + temporary: { + backgroundColor: withAlpha( + themeVars.successColorSuppl ?? themeVars.successColor ?? themeVars.primaryColor, + isDark ? 0.3 : 0.16, + fallbackSurface + ), + color: `${getReadableTextColor( + themeVars.successColorSuppl ?? themeVars.successColor ?? themeVars.primaryColor, + fallbackText, + isDark + )} !important` + }, + predefined: { + backgroundColor: withAlpha( + themeVars.primaryColorSuppl ?? themeVars.primaryColor, + isDark ? 0.3 : 0.16, + fallbackSurface + ), + color: `${getReadableTextColor( + themeVars.primaryColorSuppl ?? themeVars.primaryColor, + fallbackText, + isDark + )} !important` + }, + missing: { + backgroundColor: withAlpha( + themeVars.errorColorSuppl ?? themeVars.errorColor, + isDark ? 0.32 : 0.18, + isDark ? 'rgba(248, 113, 113, 0.28)' : 'rgba(248, 113, 113, 0.16)' + ), + color: `${getReadableTextColor( + themeVars.errorColorSuppl ?? themeVars.errorColor, + fallbackText, + isDark + )} !important` + }, + activeLine: withAlpha( + isDark ? themeVars.textColor1 : themeVars.primaryColorSuppl ?? themeVars.primaryColor, + isDark ? 0.12 : 0.08, + isDark ? 'rgba(255, 255, 255, 0.08)' : themeVars.hoverColor ?? 'rgba(0, 0, 0, 0.05)' + ) + } + return EditorView.theme({ '&': { backgroundColor: themeVars.cardColor, @@ -305,33 +630,45 @@ export function createThemeExtension(themeVars: ThemeCommonVars) { backgroundColor: themeVars.primaryColorSuppl + '60' // 60% opacity }, '.cm-activeLine': { - backgroundColor: themeVars.hoverColor + backgroundColor: highlightColors.activeLine + }, + '.cm-activeLineGutter': { + backgroundColor: highlightColors.activeLine, + color: themeVars.textColor1 }, '.cm-gutters': { backgroundColor: themeVars.cardColor, color: themeVars.textColor3, border: 'none' }, - // 变量高亮样式 + // 变量高亮样式 - 根据主题动态调整背景色和文字颜色 '.cm-variable-global': { - backgroundColor: '#e6f7ff', - borderRadius: '2px', - padding: '0 2px' - }, - '.cm-variable-temporary': { - backgroundColor: '#f6ffed', - borderRadius: '2px', - padding: '0 2px' - }, - '.cm-variable-predefined': { - backgroundColor: '#f9f0ff', - borderRadius: '2px', - padding: '0 2px' - }, - '.cm-variable-missing': { - backgroundColor: '#fff1f0', + backgroundColor: highlightColors.global.backgroundColor, + color: highlightColors.global.color, borderRadius: '2px', padding: '0 2px', + fontWeight: '500' + }, + '.cm-variable-temporary': { + backgroundColor: highlightColors.temporary.backgroundColor, + color: highlightColors.temporary.color, + borderRadius: '2px', + padding: '0 2px', + fontWeight: '500' + }, + '.cm-variable-predefined': { + backgroundColor: highlightColors.predefined.backgroundColor, + color: highlightColors.predefined.color, + borderRadius: '2px', + padding: '0 2px', + fontWeight: '500' + }, + '.cm-variable-missing': { + backgroundColor: highlightColors.missing.backgroundColor, + color: highlightColors.missing.color, + borderRadius: '2px', + padding: '0 2px', + fontWeight: '500', textDecoration: 'underline wavy red', textDecorationThickness: '2px', textUnderlineOffset: '2px'