From f262bbbe92d8843ccd66daa528f6111a2123dded Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Wed, 12 Aug 2020 18:45:36 +0100 Subject: [PATCH] Add initial color decorators --- package.json | 15 +++ src/extension.ts | 2 + src/lib/registerColorDecorator.ts | 125 +++++++++++++++++++++ src/lsp/providers/documentColorProvider.ts | 63 +++++++++++ src/lsp/server.ts | 3 + src/lsp/util/find.ts | 66 ++++++++++- src/lsp/util/resolveRange.ts | 18 +++ src/lsp/util/state.ts | 9 ++ 8 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 src/lib/registerColorDecorator.ts create mode 100644 src/lsp/providers/documentColorProvider.ts create mode 100644 src/lsp/util/resolveRange.ts diff --git a/package.json b/package.json index 0cdb709..13c2d39 100755 --- a/package.json +++ b/package.json @@ -71,6 +71,21 @@ "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.enabled": { + "type": "boolean", + "default": true, + "scope": "language-overridable" + }, + "tailwindCSS.colorDecorators.classes": { + "type": "boolean", + "default": true, + "scope": "language-overridable" + }, + "tailwindCSS.colorDecorators.cssHelpers": { + "type": "boolean", + "default": true, + "scope": "language-overridable" + }, "tailwindCSS.validate": { "type": "boolean", "default": true, 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..cb01a7b --- /dev/null +++ b/src/lib/registerColorDecorator.ts @@ -0,0 +1,125 @@ +import { window, workspace, ExtensionContext, TextEditor } from 'vscode' +import { NotificationEmitter } from './emitter' +import { LanguageClient } from 'vscode-languageclient' + +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 + let timeout: NodeJS.Timer | undefined = undefined + + 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 settings = workspace.getConfiguration( + 'tailwindCSS.colorDecorators', + editor.document + ) + + if (settings.enabled !== true) { + editor.setDecorations(colorDecorationType, []) + return + } + + let { colors } = await emitter.emit('getDocumentColors', { + document: editor.document.uri.toString(), + classes: settings.classes, + cssHelpers: settings.cssHelpers, + }) + + editor.setDecorations( + colorDecorationType, + colors + .filter(({ color }) => color !== 'rgba(0, 0, 0, 0.01)') + .map(({ range, color }) => ({ + range, + renderOptions: { before: { backgroundColor: color } }, + })) + ) + } + + function triggerUpdateDecorations() { + if (timeout) { + clearTimeout(timeout) + timeout = undefined + } + timeout = setTimeout(updateDecorations, 500) + } + + 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('tailwindCSS.colorDecorators')) { + window.visibleTextEditors.forEach(updateDecorationsInEditor) + } + }) +} diff --git a/src/lsp/providers/documentColorProvider.ts b/src/lsp/providers/documentColorProvider.ts new file mode 100644 index 0000000..5c9e9e3 --- /dev/null +++ b/src/lsp/providers/documentColorProvider.ts @@ -0,0 +1,63 @@ +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 { logFull } from '../util/logFull' +import { stringToPath } from '../util/stringToPath' +const dlv = require('dlv') + +export function registerDocumentColorProvider(state: State) { + onMessage( + state.editor.connection, + 'getDocumentColors', + async ({ document, classes, cssHelpers }) => { + let colors = [] + let doc = state.editor.documents.get(document) + if (!doc) return { colors } + + if (classes) { + 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 }) + }) + }) + } + + if (cssHelpers) { + 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: { + // start: { + // line: fn.valueRange.start.line, + // character: fn.valueRange.start.character + 1, + // }, + // end: fn.valueRange.end, + // }, + // color, + // }) + colors.push({ range: fn.valueRange, color }) + } + }) + } + + return { colors } + } + ) +} diff --git a/src/lsp/server.ts b/src/lsp/server.ts index 4b149f8..bd7cc22 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -35,6 +35,7 @@ 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) } @@ -195,6 +196,8 @@ connection.onInitialized && state.config, state.plugins, ]) + + registerDocumentColorProvider(state) }) connection.onDidChangeConfiguration((change) => { 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 09a0200..77afdfa 100644 --- a/src/lsp/util/state.ts +++ b/src/lsp/util/state.ts @@ -74,6 +74,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[]