feat(ui): 实现变量悬浮提示和高亮颜色主题适配功能

- 新增变量悬浮提示扩展,支持已存在变量的彩色标签卡片展示
- 实现全局/临时/预定义三种变量类型的视觉区分和智能颜色系统
- 引入 ExistingVariableTooltipTheme 接口,支持丰富的主题配置选项
- 使用等宽字体显示变量值,支持长内容滚动和格式保留
- 添加颜色解析与转换工具函数,实现颜色混合和透明度处理
- 实现暗色主题检测逻辑和亮度计算功能
- 优化变量高亮样式,确保在不同主题下的可读性
- 为活动行和行号槽添加主题适配样式,增强缺失变量视觉提示
- 在 VariableAwareInput 中集成悬浮提示扩展,支持主题动态切换
This commit is contained in:
linshen committed 2025-10-26 16:34:31 +08:00
1 parent 85e56161a6
commit d47f162abd
2 files changed
+420 -18

No files matched your search

@@ -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,
},
),
),
],
});
},
@@ -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<string, string> = {
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'