Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vscode): better color preview #2036

Merged
merged 3 commits into from Jan 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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')
}