feat(ui): 实现变量悬浮提示和高亮颜色主题适配功能
- 新增变量悬浮提示扩展,支持已存在变量的彩色标签卡片展示 - 实现全局/临时/预定义三种变量类型的视觉区分和智能颜色系统 - 引入 ExistingVariableTooltipTheme 接口,支持丰富的主题配置选项 - 使用等宽字体显示变量值,支持长内容滚动和格式保留 - 添加颜色解析与转换工具函数,实现颜色混合和透明度处理 - 实现暗色主题检测逻辑和亮度计算功能 - 优化变量高亮样式,确保在不同主题下的可读性 - 为活动行和行号槽添加主题适配样式,增强缺失变量视觉提示 - 在 VariableAwareInput 中集成悬浮提示扩展,支持主题动态切换
This commit is contained in:
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'
|
||||
|
||||
Reference in new issue
Block a user