diff --git a/package-lock.json b/package-lock.json index 3617ee8..f79d333 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1019,6 +1019,12 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, + "@types/debounce": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.0.tgz", + "integrity": "sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.3.tgz", @@ -1960,6 +1966,12 @@ "integrity": "sha512-8P1cDi8ebZyDxUyUprBXwidoEtiQAawYPGvpfb+Dg0G6JrQ+VozwOmm91xYC0vAv1+0VmLehEPb+isg4BGUFfA==", "dev": true }, + "debounce": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz", + "integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==", + "dev": true + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", diff --git a/package.json b/package.json index 0cdb709..7815ce1 100755 --- a/package.json +++ b/package.json @@ -71,6 +71,22 @@ "default": {}, "markdownDescription": "Enable features in languages that are not supported by default. Add a mapping here between the new language and an already supported language.\n E.g.: `{\"plaintext\": \"html\"}`" }, + "tailwindCSS.colorDecorators": { + "type": "string", + "enum": [ + "inherit", + "on", + "off" + ], + "markdownEnumDescriptions": [ + "Color decorators are rendered if `editor.colorDecorators` is enabled.", + "Color decorators are rendered.", + "Color decorators are not rendered." + ], + "default": "inherit", + "markdownDescription": "Controls whether the editor should render inline color decorators for Tailwind CSS classes and helper functions.", + "scope": "language-overridable" + }, "tailwindCSS.validate": { "type": "boolean", "default": true, @@ -157,6 +173,7 @@ }, "devDependencies": { "@ctrl/tinycolor": "^3.1.0", + "@types/debounce": "^1.2.0", "@types/mocha": "^5.2.0", "@types/moo": "^0.5.3", "@types/node": "^13.9.3", @@ -166,6 +183,7 @@ "chokidar": "^3.3.1", "concurrently": "^5.1.0", "css.escape": "^1.5.1", + "debounce": "^1.2.0", "detect-indent": "^6.0.0", "dlv": "^1.1.3", "dset": "^2.0.1", diff --git a/src/extension.ts b/src/extension.ts index 279ec96..fe982e9 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,6 +24,7 @@ import isObject from './util/isObject' import { dedupe, equal } from './util/array' import { createEmitter } from './lib/emitter' import { onMessage } from './lsp/notifications' +import { registerColorDecorator } from './lib/registerColorDecorator' const CLIENT_ID = 'tailwindcss-intellisense' const CLIENT_NAME = 'Tailwind CSS IntelliSense' @@ -152,6 +153,7 @@ export function activate(context: ExtensionContext) { client.onReady().then(() => { let emitter = createEmitter(client) registerConfigErrorHandler(emitter) + registerColorDecorator(client, context, emitter) onMessage(client, 'getConfiguration', async (scope) => { return Workspace.getConfiguration('tailwindCSS', scope) }) diff --git a/src/lib/registerColorDecorator.ts b/src/lib/registerColorDecorator.ts new file mode 100644 index 0000000..66d6767 --- /dev/null +++ b/src/lib/registerColorDecorator.ts @@ -0,0 +1,132 @@ +import { window, workspace, ExtensionContext, TextEditor } from 'vscode' +import { NotificationEmitter } from './emitter' +import { LanguageClient } from 'vscode-languageclient' +import debounce from 'debounce' + +const colorDecorationType = window.createTextEditorDecorationType({ + before: { + width: '0.8em', + height: '0.8em', + contentText: ' ', + border: '0.1em solid', + margin: '0.1em 0.2em 0', + }, + dark: { + before: { + borderColor: '#eeeeee', + }, + }, + light: { + before: { + borderColor: '#000000', + }, + }, +}) + +export function registerColorDecorator( + client: LanguageClient, + context: ExtensionContext, + emitter: NotificationEmitter +) { + let activeEditor = window.activeTextEditor + + async function updateDecorations() { + return updateDecorationsInEditor(activeEditor) + } + + async function updateDecorationsInEditor(editor: TextEditor) { + if (!editor) return + if (editor.document.uri.scheme !== 'file') return + + let workspaceFolder = workspace.getWorkspaceFolder(editor.document.uri) + if ( + !workspaceFolder || + workspaceFolder.uri.toString() !== + client.clientOptions.workspaceFolder.uri.toString() + ) { + return + } + + let preference = + workspace.getConfiguration('tailwindCSS', editor.document) + .colorDecorators || 'inherit' + + let enabled: boolean = + preference === 'inherit' + ? Boolean(workspace.getConfiguration('editor').colorDecorators) + : preference === 'on' + + if (!enabled) { + editor.setDecorations(colorDecorationType, []) + return + } + + let { colors } = await emitter.emit('getDocumentColors', { + document: editor.document.uri.toString(), + }) + + editor.setDecorations( + colorDecorationType, + colors + .filter(({ color }) => color !== 'rgba(0, 0, 0, 0.01)') + .map(({ range, color }) => ({ + range, + renderOptions: { before: { backgroundColor: color } }, + })) + ) + } + + const triggerUpdateDecorations = debounce(updateDecorations, 200) + + if (activeEditor) { + triggerUpdateDecorations() + } + + window.onDidChangeActiveTextEditor( + (editor) => { + activeEditor = editor + if (editor) { + triggerUpdateDecorations() + } + }, + null, + context.subscriptions + ) + + workspace.onDidChangeTextDocument( + (event) => { + if (activeEditor && event.document === activeEditor.document) { + triggerUpdateDecorations() + } + }, + null, + context.subscriptions + ) + + workspace.onDidOpenTextDocument( + (document) => { + if (activeEditor && document === activeEditor.document) { + triggerUpdateDecorations() + } + }, + null, + context.subscriptions + ) + + workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration('editor.colorDecorators') || + e.affectsConfiguration('tailwindCSS.colorDecorators') + ) { + window.visibleTextEditors.forEach(updateDecorationsInEditor) + } + }) + + emitter.on('configUpdated', () => { + window.visibleTextEditors.forEach(updateDecorationsInEditor) + }) + + emitter.on('configError', () => { + window.visibleTextEditors.forEach(updateDecorationsInEditor) + }) +} diff --git a/src/lsp/providers/documentColorProvider.ts b/src/lsp/providers/documentColorProvider.ts new file mode 100644 index 0000000..688ee74 --- /dev/null +++ b/src/lsp/providers/documentColorProvider.ts @@ -0,0 +1,49 @@ +import { onMessage } from '../notifications' +import { State } from '../util/state' +import { + findClassListsInDocument, + getClassNamesInClassList, + findHelperFunctionsInDocument, +} from '../util/find' +import { getClassNameParts } from '../util/getClassNameAtPosition' +import { getColor, getColorFromValue } from '../util/color' +import { stringToPath } from '../util/stringToPath' +const dlv = require('dlv') + +export function registerDocumentColorProvider(state: State) { + onMessage( + state.editor.connection, + 'getDocumentColors', + async ({ document }) => { + let colors = [] + if (!state.enabled) return { colors } + let doc = state.editor.documents.get(document) + if (!doc) return { colors } + + let classLists = findClassListsInDocument(state, doc) + classLists.forEach((classList) => { + let classNames = getClassNamesInClassList(classList) + classNames.forEach((className) => { + let parts = getClassNameParts(state, className.className) + if (!parts) return + let color = getColor(state, parts) + if (!color) return + colors.push({ range: className.range, color: color.documentation }) + }) + }) + + let helperFns = findHelperFunctionsInDocument(state, doc) + helperFns.forEach((fn) => { + let keys = stringToPath(fn.value) + let base = fn.helper === 'theme' ? ['theme'] : [] + let value = dlv(state.config, [...base, ...keys]) + let color = getColorFromValue(value) + if (color) { + colors.push({ range: fn.valueRange, color }) + } + }) + + return { colors } + } + ) +} diff --git a/src/lsp/server.ts b/src/lsp/server.ts index 4b149f8..c4cb2d1 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -35,9 +35,10 @@ import { } from './providers/diagnostics/diagnosticsProvider' import { createEmitter } from '../lib/emitter' import { provideCodeActions } from './providers/codeActions/codeActionProvider' +import { registerDocumentColorProvider } from './providers/documentColorProvider' let connection = createConnection(ProposedFeatures.all) -let state: State = { enabled: false, emitter: createEmitter(connection) } +const state: State = { enabled: false, emitter: createEmitter(connection) } let documents = new TextDocuments() let workspaceFolder: string | null @@ -73,7 +74,7 @@ connection.onInitialize( async (params: InitializeParams): Promise => { const capabilities = params.capabilities - const editorState: EditorState = { + state.editor = { connection, documents, documentSettings, @@ -99,12 +100,7 @@ connection.onInitialize( // @ts-ignore onChange: (newState: State): void => { if (newState && !newState.error) { - state = { - ...newState, - enabled: true, - emitter: state.emitter, - editor: editorState, - } + Object.assign(state, newState, { enabled: true }) connection.sendNotification('tailwindcss/configUpdated', [ state.configPath, state.config, @@ -112,11 +108,7 @@ connection.onInitialize( ]) updateAllDiagnostics(state) } else { - state = { - enabled: false, - emitter: state.emitter, - editor: editorState, - } + state.enabled = false if (newState && newState.error) { const payload: { message: string @@ -140,14 +132,9 @@ connection.onInitialize( ) if (tailwindState) { - state = { - enabled: true, - emitter: state.emitter, - editor: editorState, - ...tailwindState, - } + Object.assign(state, tailwindState, { enabled: true }) } else { - state = { enabled: false, emitter: state.emitter, editor: editorState } + state.enabled = false } return { @@ -195,6 +182,8 @@ connection.onInitialized && state.config, state.plugins, ]) + + registerDocumentColorProvider(state) }) connection.onDidChangeConfiguration((change) => { diff --git a/src/lsp/util/color.ts b/src/lsp/util/color.ts index 95d5417..31d56ec 100644 --- a/src/lsp/util/color.ts +++ b/src/lsp/util/color.ts @@ -48,17 +48,43 @@ export function getColor( ) // check that all of the values are valid colors - if (colors.some((color) => !color.isValid)) { + if (colors.some((color) => color !== 'transparent' && !color.isValid)) { return null } - // check that all of the values are the same color - const colorStrings = colors.map((color) => color.toRgbString()) - if (dedupe(colorStrings).length !== 1) { + // check that all of the values are the same color, ignoring alpha + const colorStrings = dedupe( + colors.map((color) => + color === 'transparent' + ? 'transparent' + : `${color.r}-${color.g}-${color.b}` + ) + ) + if (colorStrings.length !== 1) { return null } - return { documentation: colorStrings[0] } + if (colorStrings[0] === 'transparent') { + return { + documentation: 'rgba(0, 0, 0, 0.01)', + } + } + + const nonTransparentColors = colors.filter( + (color): color is TinyColor => color !== 'transparent' + ) + + const alphas = dedupe(nonTransparentColors.map((color) => color.a)) + + if (alphas.length === 1 || (alphas.length === 2 && alphas.includes(0))) { + return { + documentation: nonTransparentColors + .find((color) => color.a !== 0) + .toRgbString(), + } + } + + return null } export function getColorFromValue(value: unknown): string { @@ -73,9 +99,9 @@ export function getColorFromValue(value: unknown): string { return null } -function createColor(str: string): TinyColor { +function createColor(str: string): TinyColor | 'transparent' { if (str === 'transparent') { - return new TinyColor({ r: 0, g: 0, b: 0, a: 0.01 }) + return 'transparent' } // matches: rgba(, , , var(--bg-opacity)) diff --git a/src/lsp/util/find.ts b/src/lsp/util/find.ts index db5609e..8ff4ded 100644 --- a/src/lsp/util/find.ts +++ b/src/lsp/util/find.ts @@ -1,5 +1,10 @@ import { TextDocument, Range, Position } from 'vscode-languageserver' -import { DocumentClassName, DocumentClassList, State } from './state' +import { + DocumentClassName, + DocumentClassList, + State, + DocumentHelperFunction, +} from './state' import lineColumn from 'line-column' import { isCssContext, isCssDoc } from './css' import { isHtmlContext, isHtmlDoc, isSvelteDoc, isVueDoc } from './html' @@ -11,6 +16,7 @@ import { getComputedClassAttributeLexer, } from './lexers' import { getLanguageBoundaries } from './getLanguageBoundaries' +import { resolveRange } from './resolveRange' export function findAll(re: RegExp, str: string): RegExpMatchArray[] { let match: RegExpMatchArray @@ -254,6 +260,64 @@ export function findClassListsInDocument( ]) } +export function findHelperFunctionsInDocument( + state: State, + doc: TextDocument +): DocumentHelperFunction[] { + if (isCssDoc(state, doc)) { + return findHelperFunctionsInRange(doc) + } + + let boundaries = getLanguageBoundaries(state, doc) + if (!boundaries) return [] + + return flatten( + boundaries.css.map((range) => findHelperFunctionsInRange(doc, range)) + ) +} + +export function findHelperFunctionsInRange( + doc: TextDocument, + range?: Range +): DocumentHelperFunction[] { + const text = doc.getText(range) + const matches = findAll( + /(?^|\s)(?theme|config)\((?:(?')([^']+)'|(?")([^"]+)")\)/gm, + text + ) + + return matches.map((match) => { + let value = match[4] || match[6] + let startIndex = match.index + match.groups.before.length + return { + full: match[0].substr(match.groups.before.length), + value, + helper: match.groups.helper === 'theme' ? 'theme' : 'config', + quotes: match.groups.single ? "'" : '"', + range: resolveRange( + { + start: indexToPosition(text, startIndex), + end: indexToPosition(text, match.index + match[0].length), + }, + range + ), + valueRange: resolveRange( + { + start: indexToPosition( + text, + startIndex + match.groups.helper.length + 1 + ), + end: indexToPosition( + text, + startIndex + match.groups.helper.length + 1 + 1 + value.length + 1 + ), + }, + range + ), + } + }) +} + export function indexToPosition(str: string, index: number): Position { const { line, col } = lineColumn(str + '\n', index) return { line: line - 1, character: col - 1 } diff --git a/src/lsp/util/resolveRange.ts b/src/lsp/util/resolveRange.ts new file mode 100644 index 0000000..96fe343 --- /dev/null +++ b/src/lsp/util/resolveRange.ts @@ -0,0 +1,18 @@ +import { Range } from 'vscode-languageserver' + +export function resolveRange(range: Range, relativeTo?: Range) { + return { + start: { + line: (relativeTo?.start.line || 0) + range.start.line, + character: + (range.end.line === 0 ? relativeTo?.start.character || 0 : 0) + + range.start.character, + }, + end: { + line: (relativeTo?.start.line || 0) + range.end.line, + character: + (range.end.line === 0 ? relativeTo?.start.character || 0 : 0) + + range.end.character, + }, + } +} diff --git a/src/lsp/util/state.ts b/src/lsp/util/state.ts index 47b976d..8369e92 100644 --- a/src/lsp/util/state.ts +++ b/src/lsp/util/state.ts @@ -75,6 +75,15 @@ export type DocumentClassName = { classList: DocumentClassList } +export type DocumentHelperFunction = { + full: string + helper: 'theme' | 'config' + value: string + quotes: '"' | "'" + range: Range + valueRange: Range +} + export type ClassNameMeta = { source: 'base' | 'components' | 'utilities' pseudo: string[]