Skip to content

Commit

Permalink
feat(vscode): better color preview (#2036)
Browse files Browse the repository at this point in the history
* feat(vscode): better color preview

* feat(vscode): better color preview in suggestions

* refactor(vscode): enhance readability
  • Loading branch information
zam157 committed Jan 2, 2023
1 parent 1c59175 commit 4bc3272
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 81 deletions.
24 changes: 11 additions & 13 deletions packages/vscode/src/annotation.ts
Expand Up @@ -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(
Expand Down Expand Up @@ -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<string, string>() // cache for avoid duplicated color ranges

const ranges: DecorationOptions[] = (
await Promise.all(
(await getMatchedPositionsFromCode(ctx.uno, code))
.map(async (i): Promise<DecorationOptions> => {
// 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() {
Expand All @@ -132,7 +131,6 @@ export async function registerAnnotations(
)
).filter(Boolean)

_colorPositionsCache.clear()
editor.setDecorations(colorDecoration, colorRanges)

if (underline) {
Expand Down
24 changes: 13 additions & 11 deletions packages/vscode/src/autocomplete.ts
Expand Up @@ -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'
Expand Down Expand Up @@ -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)}`)
Expand Down
131 changes: 74 additions & 57 deletions 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<T extends ((...args: any) => any)>(func: T, timeFrame: number): T {
let lastTime = 0
Expand All @@ -22,6 +19,11 @@ export function throttle<T extends ((...args: any) => 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, {
Expand All @@ -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 = /(?<key>--\S+?):\s*(?<value>.+?);/gm
const cssVariables = new Map<string, string>()
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<string, string>()
export async function getColorsMap(uno: UnoGenerator, result: GenerateResult) {
const theme = uno.config.theme as Theme
const colorsMap = new Map<string, string>()

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\((?<cssVarName>--[^,|)]+)(?:,\s*(?<fallback>[^)]+))?\)/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')
}

0 comments on commit 4bc3272

Please sign in to comment.