diff --git a/packages/vscode-tailwindcss/media/icon.svg b/packages/vscode-tailwindcss/media/icon.svg
new file mode 100644
index 00000000..6a9ab499
--- /dev/null
+++ b/packages/vscode-tailwindcss/media/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json
index 6b011499..d63a096d 100755
--- a/packages/vscode-tailwindcss/package.json
+++ b/packages/vscode-tailwindcss/package.json
@@ -57,6 +57,10 @@
"command": "tailwindCSS.showOutput",
"title": "Tailwind CSS: Show Output",
"enablement": "tailwindCSS.hasOutputChannel"
+ },
+ {
+ "command": "tailwindCSS.toggleFoldClassAttributes",
+ "title": "Tailwind CSS: Toggle Fold Class Attributes"
}
],
"grammars": [
@@ -274,6 +278,11 @@
"markdownDescription": "Class variants not in the recommended order (applies in [JIT mode](https://tailwindcss.com/docs/just-in-time-mode) only)",
"scope": "language-overridable"
},
+ "tailwindCSS.experimental.foldClassAttributes": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Controls whether the editor should auto hide class attributes' values."
+ },
"tailwindCSS.experimental.classRegex": {
"type": "array",
"scope": "language-overridable"
diff --git a/packages/vscode-tailwindcss/src/classFoldingDecorator.ts b/packages/vscode-tailwindcss/src/classFoldingDecorator.ts
new file mode 100644
index 00000000..e9b759b7
--- /dev/null
+++ b/packages/vscode-tailwindcss/src/classFoldingDecorator.ts
@@ -0,0 +1,218 @@
+import {
+ window as Window,
+ workspace as Workspace,
+ Range,
+ ExtensionContext,
+ TextEditorDecorationType,
+ commands,
+ languages as Languages,
+ TextDocument,
+ FoldingRange,
+ TextEditor,
+ ConfigurationTarget,
+} from 'vscode'
+
+const TailwindIconPath = 'media/icon.svg'
+const FoldClassAttributesConfigId = 'experimental.foldClassAttributes'
+const ClassAttributeConfigId = 'classAttributes'
+
+let classRegex: RegExp | undefined
+
+let classAttributeToDecType: Record = {}
+const unFoldedDecType = Window.createTextEditorDecorationType({})
+let multilineFoldDecType: TextEditorDecorationType | undefined
+
+const multilineFoldRanges = new Set()
+
+export function initClassFoldingDecorator(context: ExtensionContext) {
+ resetState()
+
+ const classAttributes = getConfigValue(ClassAttributeConfigId)
+ setClassAttributeToDecType(classAttributes, context)
+ setClassRegex(classAttributes)
+}
+
+function resetState() {
+ Object.values(classAttributeToDecType).forEach((decType) => decType.dispose())
+ multilineFoldDecType?.dispose()
+ multilineFoldRanges.forEach((r) => foldingCommand('unfold', r))
+}
+
+function setClassAttributeToDecType(classAttributes: string[], context: ExtensionContext) {
+ const fontSize = Workspace.getConfiguration('editor').fontSize
+ const margin = 2
+
+ classAttributes.forEach((classAttribute) => {
+ classAttributeToDecType[classAttribute] = Window.createTextEditorDecorationType({
+ before: {
+ contentText: classAttribute,
+ margin: `0 ${fontSize + margin}px 0 0`,
+ },
+ after: {
+ height: `${fontSize}px`,
+ width: `${fontSize}px`,
+ contentIconPath: context.asAbsolutePath(TailwindIconPath),
+ margin: `0 0 0 ${-fontSize}px`,
+ },
+ textDecoration: 'none; display:none;',
+ })
+ })
+
+ multilineFoldDecType = Window.createTextEditorDecorationType({
+ after: {
+ height: `${fontSize}px`,
+ width: `${fontSize}px`,
+ contentIconPath: context.asAbsolutePath(TailwindIconPath),
+ margin: `0 0 0 ${margin}px`,
+ },
+ textDecoration: 'none; display:none;',
+ })
+}
+
+function setClassRegex(classAttributes: string[]) {
+ const beforeEqualSign = '(' + classAttributes.join('|') + ')'
+ const afterEqualSign = '(({(`|))|([\'"`]))((.|\n)*?)(\\2|(\\4)})'
+ classRegex = new RegExp(beforeEqualSign + '=' + afterEqualSign, 'g')
+}
+
+let timeout: NodeJS.Timer | undefined = undefined
+export function triggerUpdateDecorations(throttle = false) {
+ if (timeout) {
+ clearTimeout(timeout)
+ timeout = undefined
+ }
+ if (throttle) {
+ timeout = setTimeout(updateDecorations, 100)
+ } else {
+ updateDecorations()
+ }
+}
+
+function updateDecorations() {
+ const foldClassAttributes = getConfigValue(FoldClassAttributesConfigId)
+ let activeEditor = Window.activeTextEditor
+ if (!foldClassAttributes || !activeEditor || !classRegex) return
+
+ const matchesAndRanges = findMatchesAndRanges(activeEditor)
+
+ const unfoldRanges: Range[] = []
+ updateInlineFolding(activeEditor, matchesAndRanges, unfoldRanges)
+ updateMultilineFolding(activeEditor, matchesAndRanges, unfoldRanges)
+
+ activeEditor.setDecorations(unFoldedDecType, unfoldRanges)
+}
+
+function findMatchesAndRanges(activeEditor: TextEditor) {
+ const matchesAndRanges: [match: RegExpExecArray, range: Range][] = []
+
+ const text = activeEditor.document.getText()
+ let match
+ while ((match = classRegex.exec(text))) {
+ if (match && !match[0]) continue
+ const startPosition = activeEditor.document.positionAt(match.index)
+ const endPosition = activeEditor.document.positionAt(match.index + match[0].length)
+ const range = new Range(startPosition, endPosition)
+
+ matchesAndRanges.push([match, range])
+ }
+ return matchesAndRanges
+}
+
+function updateInlineFolding(
+ activeEditor: TextEditor,
+ matchesAndRanges: [match: RegExpExecArray, range: Range][],
+ unfoldRanges: Range[]
+) {
+ const foldDecTypeToRanges = new Map()
+ Object.values(classAttributeToDecType).forEach((decType) => foldDecTypeToRanges.set(decType, []))
+
+ for (let [match, range] of matchesAndRanges) {
+ if (!range.isSingleLine) continue
+
+ //Unfold if range is within user selection, accounting for both single or multiple cursors
+ if (activeEditor.selections.some((s) => range.intersection(s))) {
+ unfoldRanges.push(range)
+ } else {
+ const classAttribute = match[1]
+ foldDecTypeToRanges.get(classAttributeToDecType[classAttribute]).push(range)
+ }
+ }
+
+ foldDecTypeToRanges.forEach((ranges, decType) => activeEditor.setDecorations(decType, ranges))
+}
+
+function updateMultilineFolding(
+ activeEditor: TextEditor,
+ matchesAndRanges: [match: RegExpExecArray, range: Range][],
+ unfoldRanges: Range[]
+) {
+ const foldRanges = []
+ for (let [match, range] of matchesAndRanges) {
+ if (range.isSingleLine) continue
+
+ const classAttribute = match[1]
+ const afterClassAttributePosition = range.start.translate(0, classAttribute.length)
+ const endOfLinePosition = activeEditor.document.lineAt(afterClassAttributePosition).range.end
+ const firstLineRange = new Range(afterClassAttributePosition, endOfLinePosition)
+
+ if (
+ activeEditor.selections.some((s) => s.start.line === range.end.line) ||
+ activeEditor.selections.some((s) => s.start.line === range.start.line) ||
+ activeEditor.selections.some((s) => range.intersection(s))
+ ) {
+ unfoldRanges.push(firstLineRange)
+ foldingCommand('unfold', range)
+ } else {
+ foldRanges.push(firstLineRange)
+ foldingCommand('fold', range)
+ multilineFoldRanges.add(range)
+ }
+ }
+
+ activeEditor.setDecorations(multilineFoldDecType, foldRanges)
+}
+
+export function registerFoldingRangeProvider() {
+ return Languages.registerFoldingRangeProvider(
+ { language: '*', scheme: 'file' },
+ {
+ provideFoldingRanges(document: TextDocument) {
+ const ranges = []
+
+ let match
+ while ((match = classRegex.exec(document.getText()))) {
+ if (match && !match[0]) continue
+
+ const startPosition = document.positionAt(match.index)
+ const endPosition = document.positionAt(match.index + match[0].length)
+
+ if (startPosition.line !== endPosition.line) {
+ ranges.push(new FoldingRange(startPosition.line, endPosition.line))
+ }
+ }
+ return ranges
+ },
+ }
+ )
+}
+
+export function toggleFoldClassAttributes() {
+ const foldClassAttributes = getConfigValue(FoldClassAttributesConfigId)
+ return Workspace.getConfiguration('tailwindCSS').update(
+ FoldClassAttributesConfigId,
+ !foldClassAttributes,
+ ConfigurationTarget.Global
+ )
+}
+
+function getConfigValue(configId: string) {
+ return Workspace.getConfiguration('tailwindCSS').get(configId)
+}
+
+function foldingCommand(command: 'fold' | 'unfold', range: Range) {
+ return commands.executeCommand(`editor.${command}`, {
+ level: 1,
+ direction: 'down',
+ selectionLines: [range.end.line],
+ })
+}
diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts
index 8455948f..c98f2cb2 100755
--- a/packages/vscode-tailwindcss/src/extension.ts
+++ b/packages/vscode-tailwindcss/src/extension.ts
@@ -43,6 +43,12 @@ import { dedupe, equal } from 'tailwindcss-language-service/src/util/array'
import namedColors from 'color-name'
import minimatch from 'minimatch'
import { CONFIG_GLOB, CSS_GLOB } from 'tailwindcss-language-server/src/lib/constants'
+import {
+ registerFoldingRangeProvider,
+ toggleFoldClassAttributes,
+ triggerUpdateDecorations,
+ initClassFoldingDecorator,
+} from './classFoldingDecorator'
const colorNames = Object.keys(namedColors)
@@ -269,6 +275,15 @@ export async function activate(context: ExtensionContext) {
clients.delete(folder.uri.toString())
bootClientForFolderIfNeeded(folder)
}
+
+ if (
+ event.affectsConfiguration('tailwindCSS.classAttributes') ||
+ event.affectsConfiguration('tailwindCSS.experimental.foldClassAttributes') ||
+ event.affectsConfiguration('editor.fontSize')
+ ) {
+ initClassFoldingDecorator(context)
+ triggerUpdateDecorations()
+ }
})
)
@@ -709,6 +724,27 @@ export async function activate(context: ExtensionContext) {
}
})
)
+
+ initClassFoldingDecorator(context)
+ context.subscriptions.push(registerFoldingRangeProvider())
+
+ context.subscriptions.push(
+ commands.registerCommand('tailwindCSS.toggleFoldClassAttributes', toggleFoldClassAttributes)
+ )
+
+ context.subscriptions.push(
+ Window.onDidChangeActiveTextEditor((e) => {
+ if (e) triggerUpdateDecorations()
+ })
+ )
+
+ context.subscriptions.push(
+ Window.onDidChangeTextEditorSelection((e) => {
+ if (e) triggerUpdateDecorations(true)
+ })
+ )
+
+ triggerUpdateDecorations()
}
export function deactivate(): Thenable {