From 4bc32722b8fd5f9ddbe1a42914eba728633e54a2 Mon Sep 17 00:00:00 2001 From: Zam <24277775+zam157@users.noreply.github.com> Date: Mon, 2 Jan 2023 13:08:35 +0800 Subject: [PATCH] feat(vscode): better color preview (#2036) * feat(vscode): better color preview * feat(vscode): better color preview in suggestions * refactor(vscode): enhance readability --- packages/vscode/src/annotation.ts | 24 +++-- packages/vscode/src/autocomplete.ts | 24 ++--- packages/vscode/src/utils.ts | 131 ++++++++++++++++------------ 3 files changed, 98 insertions(+), 81 deletions(-) diff --git a/packages/vscode/src/annotation.ts b/packages/vscode/src/annotation.ts index 3a904469d8..75554d1d49 100644 --- a/packages/vscode/src/annotation.ts +++ b/packages/vscode/src/annotation.ts @@ -3,7 +3,7 @@ import type { DecorationOptions, ExtensionContext, StatusBarItem } from 'vscode' import { DecorationRangeBehavior, MarkdownString, Range, window, workspace } from 'vscode' import { INCLUDE_COMMENT_IDE, getMatchedPositionsFromCode, isCssId } from './integration' import { log } from './log' -import { getColorsMap, getPrettiedMarkdown, isSubdir, throttle } from './utils' +import { getColorString, getPrettiedMarkdown, isSubdir, throttle } from './utils' import type { ContextLoader } from './contextLoader' export async function registerAnnotations( @@ -97,25 +97,24 @@ export async function registerAnnotations( const result = await ctx.uno.generate(code, { id, preflights: false, minify: true }) - const colorsMap = await getColorsMap(ctx.uno, result) const colorRanges: DecorationOptions[] = [] - const _colorPositionsCache = new Map() // cache for avoid duplicated color ranges const ranges: DecorationOptions[] = ( await Promise.all( (await getMatchedPositionsFromCode(ctx.uno, code)) .map(async (i): Promise => { - // side-effect: update colorRanges - if (colorPreview && colorsMap.has(i[2]) && !_colorPositionsCache.has(`${i[0]}:${i[1]}`)) { - _colorPositionsCache.set(`${i[0]}:${i[1]}`, i[2]) - colorRanges.push({ - range: new Range(doc.positionAt(i[0]), doc.positionAt(i[1])), - renderOptions: { before: { backgroundColor: colorsMap.get(i[2]) } }, - }) - } - try { const md = await getPrettiedMarkdown(ctx!.uno, i[2]) + + if (colorPreview) { + const color = getColorString(md) + if (color) { + colorRanges.push({ + range: new Range(doc.positionAt(i[0]), doc.positionAt(i[1])), + renderOptions: { before: { backgroundColor: color } }, + }) + } + } return { range: new Range(doc.positionAt(i[0]), doc.positionAt(i[1])), get hoverMessage() { @@ -132,7 +131,6 @@ export async function registerAnnotations( ) ).filter(Boolean) - _colorPositionsCache.clear() editor.setDecorations(colorDecoration, colorRanges) if (underline) { diff --git a/packages/vscode/src/autocomplete.ts b/packages/vscode/src/autocomplete.ts index 3ef4cdd152..57c91e30bb 100644 --- a/packages/vscode/src/autocomplete.ts +++ b/packages/vscode/src/autocomplete.ts @@ -3,7 +3,7 @@ import { createAutocomplete } from '@unocss/autocomplete' import type { CompletionItemProvider, ExtensionContext } from 'vscode' import { CompletionItem, CompletionItemKind, CompletionList, MarkdownString, Range, languages } from 'vscode' import type { UnoGenerator, UnocssPluginContext } from '@unocss/core' -import { body2ColorValue, getPrettiedCSS, getPrettiedMarkdown, isSubdir } from './utils' +import { getCSS, getColorString, getPrettiedCSS, getPrettiedMarkdown, isSubdir } from './utils' import { log } from './log' import type { ContextLoader } from './contextLoader' import { isCssId } from './integration' @@ -96,23 +96,25 @@ export async function registerAutoComplete( if (!result.suggestions.length) return - const theme = ctx?.uno.config.theme - return new CompletionList(result.suggestions.map(([value, label]) => { - const colorValue = theme ? body2ColorValue(value, theme) : null - const itemKind = colorValue?.color ? CompletionItemKind.Color : CompletionItemKind.EnumMember - - const resolved = result.resolveReplacement(value) + const completionItems: UnoCompletionItem[] = [] + for (const [value, label] of result.suggestions) { + const css = await getCSS(ctx!.uno, value) + const colorString = getColorString(css) + const itemKind = colorString ? CompletionItemKind.Color : CompletionItemKind.EnumMember const item = new UnoCompletionItem(label, itemKind, ctx!.uno) + const resolved = result.resolveReplacement(value) + item.insertText = resolved.replacement item.range = new Range(doc.positionAt(resolved.start), doc.positionAt(resolved.end)) - if (colorValue?.color) { - item.documentation = colorValue?.color + if (colorString) { + item.documentation = colorString item.sortText = /-\d$/.test(label) ? '1' : '2' // reorder color completions } + completionItems.push(item) + } - return item - }), true) + return new CompletionList(completionItems, true) } catch (e) { log.appendLine(`⚠️ ${String(e)}`) diff --git a/packages/vscode/src/utils.ts b/packages/vscode/src/utils.ts index 429f2cf65a..051d420164 100644 --- a/packages/vscode/src/utils.ts +++ b/packages/vscode/src/utils.ts @@ -1,10 +1,7 @@ import path from 'path' -import type { GenerateResult, UnoGenerator } from '@unocss/core' +import type { UnoGenerator } from '@unocss/core' import prettier from 'prettier/standalone' import parserCSS from 'prettier/parser-postcss' -import type { Theme } from '@unocss/preset-mini' -import { parseColor } from '@unocss/preset-mini' -import { colorToString } from '@unocss/preset-mini/utils' export function throttle any)>(func: T, timeFrame: number): T { let lastTime = 0 @@ -22,6 +19,11 @@ export function throttle any)>(func: T, timeFrame: } as T } +export const getCSS = async (uno: UnoGenerator, utilName: string) => { + const { css } = await uno.generate(utilName, { preflights: false, safelist: false }) + return css +} + export async function getPrettiedCSS(uno: UnoGenerator, util: string) { const result = (await uno.generate(new Set([util]), { preflights: false, safelist: false })) const prettified = prettier.format(result.css, { @@ -39,69 +41,84 @@ export async function getPrettiedMarkdown(uno: UnoGenerator, util: string) { return `\`\`\`css\n${(await getPrettiedCSS(uno, util)).prettified}\n\`\`\`` } -export function body2ColorValue(body: string, theme: Theme) { - const themeColorNames = Object.keys(theme.colors ?? {}) - const colorNames = themeColorNames.concat(themeColorNames.map(colorName => colorName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase())) - - for (const colorName of colorNames) { - const nameIndex = body.indexOf(colorName) - - if (nameIndex > -1) { - const parsedResult = parseColor(body.substring(nameIndex), theme) - if (parsedResult?.cssColor) - return parsedResult - } +const getCssVariables = (code: string) => { + const regex = /(?--\S+?):\s*(?.+?);/gm + const cssVariables = new Map() + for (const match of code.matchAll(regex)) { + const key = match.groups?.key + if (key) + cssVariables.set(key, match.groups?.value ?? '') } - return null + return cssVariables } -const matchedAttributifyRE = /(?<=^\[.+~=").*(?="\]$)/ -const matchedValuelessAttributifyRE = /(?<=^\[).+(?==""\]$)/ -const _colorsMapCache = new Map() -export async function getColorsMap(uno: UnoGenerator, result: GenerateResult) { - const theme = uno.config.theme as Theme - const colorsMap = new Map() - - for (const i of result.matched) { - if (!(await isColorUtility(uno, i))) - continue - - const matchedValueless = i.match(matchedValuelessAttributifyRE)?.[0] - const colorKey = matchedValueless ?? i.replace('~="', '="') - - const cachedColor = _colorsMapCache.get(colorKey) - if (cachedColor) { - colorsMap.set(colorKey, cachedColor) - continue - } - - const matchedAttr = i.match(matchedAttributifyRE)?.[0] ?? matchedValueless - const body = (matchedAttr ?? i) - .split(':').slice(-1)[0] ?? '' // remove prefix e.g. `dark:` `hover:` - - if (body) { - const colorValue = body2ColorValue(body, theme) - if (colorValue) { - const colorString = colorToString(colorValue.cssColor!, colorValue.alpha) - colorsMap.set(colorKey, colorString) - _colorsMapCache.set(colorKey, colorString) - } - } +const matchCssVarNameRegex = /var\((?--[^,|)]+)(?:,\s*(?[^)]+))?\)/gm +const cssColorRegex = /(?:#|0x)(?:[a-f0-9]{3}|[a-f0-9]{6})\b|(?:rgb|hsl)a?\(.*\)/gm + +/** + * Get CSS color string from CSS string + * + * @example Input with CSS var + * ```css + *.dark [border="dark\:gray-700"] { + * --un-border-opacity: 1; + * border-color: rgba(55, 65, 81, var(--un-border-opacity)); + *} + * ``` + * return `rgba(55, 65, 81, 1)` + * + * @example Input with no-value CSS var and its fallback value + * ```css + *.bg-brand-primary { + * background-color: hsla(217, 78%, 51%, var(--no-value, 0.5)); + *} + * ``` + * return `hsla(217, 78%, 51%, 0.5)` + * + * @example Input with no-value CSS var + * ```css + *.bg-brand-primary { + * background-color: hsla(217, 78%, 51%, var(--no-value)); + *} + * ``` + * return `hsla(217, 78%, 51%)` + * + * @param str - CSS string + * @returns The **first** CSS color string (hex, rgb[a], hsl[a]) or `undefined` + */ +export const getColorString = (str: string) => { + let colorString = str.match(cssColorRegex)?.[0] // e.g rgba(248, 113, 113, var(--maybe-css-var)) + + if (!colorString) + return + + const cssVars = getCssVariables(str) + + // replace `var(...)` with its value + for (const match of colorString.matchAll(matchCssVarNameRegex)) { + const matchedString = match[0] + const cssVarName = match.groups?.cssVarName + const fallback = match.groups?.fallback + + if (cssVarName && cssVars.get(cssVarName)) + // rgba(248, 113, 113, var(--un-text-opacity)) => rgba(248, 113, 113, 1) + colorString = colorString.replaceAll(matchedString, cssVars.get(cssVarName) ?? matchedString) + else if (fallback) + // rgba(248, 113, 113, var(--no-value, 0.5)) => rgba(248, 113, 113, 0.5) + colorString = colorString.replaceAll(matchedString, fallback) + + // rgba(248, 113, 113, var(--no-value)) => rgba(248, 113, 113) + colorString = colorString.replaceAll(/,?\s+var\(--.*?\)/gm, '') } - if (_colorsMapCache.size > 5000) - _colorsMapCache.clear() + // if (!(new TinyColor(colorString).isValid)) + // return - return colorsMap + return colorString } export function isSubdir(parent: string, child: string) { const relative = path.relative(parent, child) return relative && !relative.startsWith('..') && !path.isAbsolute(relative) } - -async function isColorUtility(uno: UnoGenerator, utilName: string) { - const { css } = await uno.generate(utilName, { preflights: false, safelist: false }) - return css.includes('color') -}