From 4896c4ae566443722adff72b722e29f3ff7f7a3f Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:13:08 +0530 Subject: [PATCH 1/3] feat(lsp): add document color chips with regex fallback Implement textDocument/documentColor and colorPresentation for LSP color previews, share chip UI with the regex colorView, and keep regex as a fallback for ranges the server does not cover. --- src/cm/colorChip.ts | 175 ++++++++++++ src/cm/colorView.ts | 205 ++++++-------- src/cm/lsp/clientManager.ts | 7 + src/cm/lsp/documentColors.ts | 531 +++++++++++++++++++++++++++++++++++ src/cm/lsp/index.ts | 14 + src/cm/lsp/types.ts | 2 + 6 files changed, 811 insertions(+), 123 deletions(-) create mode 100644 src/cm/colorChip.ts create mode 100644 src/cm/lsp/documentColors.ts diff --git a/src/cm/colorChip.ts b/src/cm/colorChip.ts new file mode 100644 index 000000000..cbaa99976 --- /dev/null +++ b/src/cm/colorChip.ts @@ -0,0 +1,175 @@ +import { LSPPlugin } from "@codemirror/lsp-client"; +import type { Range } from "@codemirror/state"; +import { Decoration, EditorView, WidgetType } from "@codemirror/view"; +import pickColor from "dialogs/color"; +import color from "utils/color"; +import type { Color as LspColor } from "vscode-languageserver-types"; + +export const COLOR_CHIP_CLASS = "cm-color-chip"; +export const COLOR_CHIP_SOURCE_ATTR = "data-color-source"; + +export type ColorChipSource = "regex" | "lsp"; + +export interface ColorChipPayload { + from: number; + to: number; + css: string; + pickerSeed?: string; + source: ColorChipSource; +} + +const chipState = new WeakMap(); + +export function getColorChipPayload( + el: HTMLElement | null | undefined, +): ColorChipPayload | undefined { + if (!el) return undefined; + return chipState.get(el); +} + +export function findColorChip(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Element)) return null; + return target.closest(`.${COLOR_CHIP_CLASS}`) as HTMLElement | null; +} + +export class ColorChipWidget extends WidgetType { + constructor(readonly payload: ColorChipPayload) { + super(); + } + + eq(other: ColorChipWidget): boolean { + return ( + other.payload.from === this.payload.from && + other.payload.to === this.payload.to && + other.payload.css === this.payload.css && + other.payload.source === this.payload.source && + (other.payload.pickerSeed || "") === (this.payload.pickerSeed || "") + ); + } + + toDOM(): HTMLElement { + const el = document.createElement("span"); + el.className = COLOR_CHIP_CLASS; + el.setAttribute(COLOR_CHIP_SOURCE_ATTR, this.payload.source); + el.style.display = "inline-block"; + el.style.width = "0.9em"; + el.style.height = "0.9em"; + el.style.borderRadius = "2px"; + el.style.verticalAlign = "middle"; + el.style.margin = "0 2px"; + el.style.boxSizing = "border-box"; + el.style.border = "1px solid rgba(0,0,0,0.2)"; + el.style.backgroundColor = this.payload.css; + el.style.cursor = "pointer"; + el.style.userSelect = "none"; + el.title = this.payload.css; + el.dataset["color"] = this.payload.pickerSeed || this.payload.css; + el.dataset["colorraw"] = this.payload.css; + chipState.set(el, this.payload); + return el; + } + + ignoreEvent(): boolean { + return false; + } +} + +export function colorChipDecoration( + payload: ColorChipPayload, +): Range { + return Decoration.widget({ + widget: new ColorChipWidget(payload), + side: -1, + }).range(payload.from); +} + +export function isViewEditable(view: EditorView): boolean { + const readOnly = view.contentDOM.ariaReadOnly === "true"; + const editable = view.contentDOM.contentEditable === "true"; + return !readOnly && editable; +} + +export function hasLspColorProvider(view: EditorView): boolean { + const lsp = LSPPlugin.get(view) as { + client?: { + connected?: boolean; + serverCapabilities?: { colorProvider?: boolean | object } | null; + }; + } | null; + return !!( + lsp?.client?.connected && lsp.client.serverCapabilities?.colorProvider + ); +} + +export async function openColorPicker(seed: string): Promise { + try { + const picked = await pickColor(seed || "#ffffff"); + return picked || null; + } catch { + return null; + } +} + +function clamp01(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.min(1, Math.max(0, n)); +} + +function channelToByte(n: number): number { + return Math.round(clamp01(n) * 255); +} + +export function lspColorToCss(c: LspColor): string { + const r = channelToByte(c.red); + const g = channelToByte(c.green); + const b = channelToByte(c.blue); + const a = clamp01(c.alpha ?? 1); + if (a < 1) { + const alpha = Number(a.toFixed(3)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return `rgb(${r}, ${g}, ${b})`; +} + +export function lspColorToHex(c: LspColor): string { + const r = channelToByte(c.red).toString(16).padStart(2, "0"); + const g = channelToByte(c.green).toString(16).padStart(2, "0"); + const b = channelToByte(c.blue).toString(16).padStart(2, "0"); + const a = clamp01(c.alpha ?? 1); + if (a < 1) { + const aa = channelToByte(a).toString(16).padStart(2, "0"); + return `#${r}${g}${b}${aa}`; + } + return `#${r}${g}${b}`; +} + +export function cssToLspColor(css: string): LspColor | null { + if (!css || typeof css !== "string") return null; + try { + const { r, g, b, a } = color(css.trim()).rgb; + return { + red: clamp01(r / 255), + green: clamp01(g / 255), + blue: clamp01(b / 255), + alpha: clamp01(a), + }; + } catch { + return null; + } +} + +export function detectColorFormat( + text: string, +): "hex" | "rgb" | "hsl" | "other" { + const t = text.trim().toLowerCase(); + if (t.startsWith("#")) return "hex"; + if (t.startsWith("rgb")) return "rgb"; + if (t.startsWith("hsl")) return "hsl"; + return "other"; +} + +export const colorChipTheme = EditorView.baseTheme({ + [`.${COLOR_CHIP_CLASS}`]: { + userSelect: "none", + }, +}); diff --git a/src/cm/colorView.ts b/src/cm/colorView.ts index bd38f7657..9300f8ed2 100644 --- a/src/cm/colorView.ts +++ b/src/cm/colorView.ts @@ -1,38 +1,27 @@ import type { Range, Text } from "@codemirror/state"; import type { DecorationSet, ViewUpdate } from "@codemirror/view"; -import { - Decoration, - EditorView, - ViewPlugin, - WidgetType, -} from "@codemirror/view"; -import pickColor from "dialogs/color"; +import { Decoration, EditorView, ViewPlugin } from "@codemirror/view"; import color from "utils/color"; import { colorRegex, isValidColor } from "utils/color/regex"; - -interface ColorWidgetState { - from: number; - to: number; - colorType: string; - alpha?: string; - colorRaw: string; -} - -interface ColorWidgetParams extends ColorWidgetState { - color: string; -} +import { + COLOR_CHIP_CLASS, + colorChipDecoration, + colorChipTheme, + findColorChip, + getColorChipPayload, + isViewEditable, + openColorPicker, + type ColorChipPayload, +} from "./colorChip"; +import { lspDocumentColorsField } from "./lsp/documentColors"; interface DocRange { from: number; to: number; } -// WeakMap to carry state from widget DOM back into handler -const colorState = new WeakMap(); - const RGBG = new RegExp(colorRegex.anyGlobal); -const enumColorType = { hex: "hex", rgb: "rgb", hsl: "hsl", named: "named" }; const MAX_SCAN_CHARS = 20000; const MAX_COLOR_CHIPS = 150; @@ -54,7 +43,7 @@ function isAlpha(char: string): boolean { if (!char) return false; const code = char.charCodeAt(0); return ( - (code >= 65 && code <= 90) || // A-Z + (code >= 65 && code <= 90) || (code >= 97 && code <= 122) ); } @@ -117,53 +106,6 @@ function shouldRenderColor(doc: Text, start: number, end: number): boolean { return true; } -class ColorWidget extends WidgetType { - state: ColorWidgetState; - color: string; - colorRaw: string; - - constructor({ color, colorRaw, ...state }: ColorWidgetParams) { - super(); - this.state = { ...state, colorRaw }; // from, to, colorType, alpha, original text - this.color = color; // hex for input value - this.colorRaw = this.state.colorRaw; // original css color string - } - - eq(other: ColorWidget): boolean { - return ( - other.state.colorType === this.state.colorType && - other.color === this.color && - other.colorRaw === this.colorRaw && - other.state.from === this.state.from && - other.state.to === this.state.to && - (other.state.alpha || "") === (this.state.alpha || "") - ); - } - - toDOM(): HTMLElement { - const wrapper = document.createElement("span"); - wrapper.className = "cm-color-chip"; - wrapper.style.display = "inline-block"; - wrapper.style.width = "0.9em"; - wrapper.style.height = "0.9em"; - wrapper.style.borderRadius = "2px"; - wrapper.style.verticalAlign = "middle"; - wrapper.style.margin = "0 2px"; - wrapper.style.boxSizing = "border-box"; - wrapper.style.border = "1px solid rgba(0,0,0,0.2)"; - wrapper.style.backgroundColor = this.colorRaw; - wrapper.dataset["color"] = this.color; - wrapper.dataset["colorraw"] = this.colorRaw; - wrapper.style.cursor = "pointer"; - colorState.set(wrapper, this.state); - return wrapper; - } - - ignoreEvent(): boolean { - return false; - } -} - function normalizeRanges(ranges: readonly DocRange[]): DocRange[] { if (!ranges.length) return []; const sorted = [...ranges] @@ -259,40 +201,48 @@ function subtractRanges( return result; } +function getLspCoveredRanges(view: EditorView): DocRange[] { + const colors = view.state.field(lspDocumentColorsField, false); + if (!colors?.length) return []; + return colors.map((c) => ({ from: c.from, to: c.to })); +} + function colorRanges( view: EditorView, ranges: readonly DocRange[], ): Range[] { const deco: Range[] = []; const doc = view.state.doc; + const lspCovered = getLspCoveredRanges(view); let scannedChars = 0; + for (const { from, to } of ranges) { if (deco.length >= MAX_COLOR_CHIPS || scannedChars >= MAX_SCAN_CHARS) break; const scanTo = Math.min(to, from + (MAX_SCAN_CHARS - scannedChars)); if (scanTo <= from) continue; const text = doc.sliceString(from, scanTo); scannedChars += text.length; - // Any color using global matcher from utils (captures named/rgb/rgba/hsl/hsla/hex) RGBG.lastIndex = 0; for (let m: RegExpExecArray | null; (m = RGBG.exec(text)); ) { if (deco.length >= MAX_COLOR_CHIPS) break; const raw = m[2]; const start = from + m.index + m[1].length; const end = start + raw.length; + // Skip spans already covered by LSP documentColor chips + if (lspCovered.length && intersectsRanges(start, end, lspCovered)) { + continue; + } if (!shouldRenderColor(doc, start, end)) continue; const c = color(raw); const colorHex = c.hex.toString(false); deco.push( - Decoration.widget({ - widget: new ColorWidget({ - from: start, - to: end, - color: colorHex, - colorRaw: raw, - colorType: enumColorType.named, - }), - side: -1, - }).range(start), + colorChipDecoration({ + from: start, + to: end, + css: raw, + pickerSeed: colorHex, + source: "regex", + }), ); } } @@ -314,13 +264,24 @@ class ColorViewPlugin { } update(update: ViewUpdate): void { + const lspChanged = + update.startState.field(lspDocumentColorsField, false) !== + update.state.field(lspDocumentColorsField, false); + if (update.docChanged || update.viewportChanged || update.geometryChanged) { this.scheduleDecorations(update); } - const readOnly = update.view.contentDOM.ariaReadOnly === "true"; - const editable = update.view.contentDOM.contentEditable === "true"; - const canBeEdited = readOnly === false && editable; - this.changePicker(update.view, canBeEdited); + + // Rescan when LSP colors arrive/change so regex fills gaps / drops overlaps + if (lspChanged) { + this.pendingView = update.view; + this.pendingDirtyRanges = normalizeRanges([ + ...this.pendingDirtyRanges, + ...update.view.visibleRanges, + ]); + this.visibleRanges = normalizeRanges(update.view.visibleRanges); + this.scheduleFlush(); + } } scheduleVisibleRanges(view: EditorView): void { @@ -379,29 +340,20 @@ class ColorViewPlugin { const visibleRanges = normalizeRanges(view.visibleRanges); const dirtyVisibleRanges = intersectRanges(dirtyRanges, visibleRanges); const add = colorRanges(view, dirtyVisibleRanges); + const lspCovered = getLspCoveredRanges(view); this.decorations = this.decorations.update({ filter: (from, to) => intersectsRanges(from, to, visibleRanges) && - !intersectsRanges(from, to, dirtyVisibleRanges), + !intersectsRanges(from, to, dirtyVisibleRanges) && + // Drop leftover regex chips that LSP now covers + !(lspCovered.length && intersectsRanges(from, to, lspCovered)), add, sort: true, }); this.visibleRanges = visibleRanges; } - changePicker(view: EditorView, canBeEdited: boolean): void { - const doms = view.contentDOM.querySelectorAll("input[type=color]"); - doms.forEach((inp) => { - const input = inp as HTMLInputElement; - if (canBeEdited) { - input.removeAttribute("disabled"); - } else { - input.setAttribute("disabled", ""); - } - }); - } - destroy(): void { if (this.flushTimer) { window.clearTimeout(this.flushTimer); @@ -413,39 +365,46 @@ class ColorViewPlugin { } } -export const colorView = (showPicker = true) => +async function handleRegexColorPick( + view: EditorView, + payload: ColorChipPayload, +): Promise { + const atClick = view.state.doc.sliceString(payload.from, payload.to); + if (atClick !== payload.css || !isValidColor(atClick)) return; + + const picked = await openColorPicker(payload.pickerSeed || payload.css); + if (!picked) return; + + const current = view.state.doc.sliceString(payload.from, payload.to); + if (current !== atClick || !isValidColor(current)) return; + + view.dispatch({ + changes: { from: payload.from, to: payload.to, insert: picked }, + }); +} + +export const colorView = (showPicker = true) => [ + colorChipTheme, ViewPlugin.fromClass(ColorViewPlugin, { decorations: (v) => v.decorations, eventHandlers: { click: (e: PointerEvent, view: EditorView): boolean => { - const target = e.target as HTMLElement | null; - const chip = target?.closest?.(".cm-color-chip") as HTMLElement | null; + const chip = findColorChip(e.target); if (!chip) return false; + + const payload = getColorChipPayload(chip); + if (!payload || payload.source !== "regex") return false; + if (!showPicker) return true; - // Respect read-only and setting toggle - const readOnly = view.contentDOM.ariaReadOnly === "true"; - const editable = view.contentDOM.contentEditable === "true"; - const canBeEdited = !readOnly && editable; - if (!canBeEdited) return true; - const data = colorState.get(chip); - if (!data) return false; - - pickColor(chip.dataset.colorraw || chip.dataset.color || "") - .then((picked: string | null) => { - if (!picked) return; - const current = view.state.doc.sliceString(data.from, data.to); - if (current !== data.colorRaw || !isValidColor(current)) return; - view.dispatch({ - changes: { from: data.from, to: data.to, insert: picked }, - }); - }) - .catch(() => { - /* ignore */ - }); + if (!isViewEditable(view)) return true; + void handleRegexColorPick(view, payload); return true; }, }, - }); + }), +]; export default colorView; + +export { COLOR_CHIP_CLASS }; diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index e14041a85..4dd47daa0 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -14,6 +14,7 @@ import Uri from "utils/Uri"; import Url from "utils/Url"; import { clearDiagnosticsEffect } from "./diagnostics"; import { supportsBuiltinFormatting } from "./formattingSupport"; +import { documentColorsExtension } from "./documentColors"; import { inlayHintsExtension } from "./inlayHints"; import { addLspLog } from "./logs"; import { selectRuntimeProvider } from "./runtimeProviders"; @@ -210,6 +211,7 @@ function buildBuiltinExtensions( signature: includeSignature = true, diagnostics: includeDiagnostics = true, inlayHints: includeInlayHints = false, + documentColors: includeDocumentColors = true, } = config; const extensions: Extension[] = []; @@ -227,6 +229,10 @@ function buildBuiltinExtensions( const hintsExt = inlayHintsExtension(); extensions.push(hintsExt as LSPClientExtension as Extension); } + if (includeDocumentColors) { + const colorsExt = documentColorsExtension(); + extensions.push(colorsExt as LSPClientExtension as Extension); + } return { extensions, diagnosticsExtension }; } @@ -597,6 +603,7 @@ export class LspClientManager { diagnostics: builtinConfig.diagnostics !== false, inlayHints: builtinConfig.inlayHints === true, formatting: builtinConfig.formatting !== false, + documentColors: builtinConfig.documentColors !== false, }) : { extensions: [], diagnosticsExtension: null }; diff --git a/src/cm/lsp/documentColors.ts b/src/cm/lsp/documentColors.ts new file mode 100644 index 000000000..32c6a508a --- /dev/null +++ b/src/cm/lsp/documentColors.ts @@ -0,0 +1,531 @@ +import type { LSPClientExtension } from "@codemirror/lsp-client"; +import { LSPPlugin } from "@codemirror/lsp-client"; +import type { Extension, Range } from "@codemirror/state"; +import { MapMode, StateEffect, StateField } from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, +} from "@codemirror/view"; +import appSettings from "lib/settings"; +import type { + Color, + ColorInformation, + ColorPresentation, + Range as LspRange, + TextEdit, +} from "vscode-languageserver-types"; +import { + colorChipDecoration, + colorChipTheme, + cssToLspColor, + detectColorFormat, + findColorChip, + getColorChipPayload, + hasLspColorProvider, + isViewEditable, + lspColorToCss, + lspColorToHex, + openColorPicker, + type ColorChipPayload, +} from "../colorChip"; +import type { LSPPluginAPI } from "./types"; + +export interface DocumentColorsConfig { + enabled?: boolean; + debounceMs?: number; + maxColors?: number; + viewportBufferLines?: number; + showPicker?: boolean; +} + +export interface LspDocumentColor { + from: number; + to: number; + color: Color; + css: string; +} + +interface DocumentColorParams { + textDocument: { uri: string }; +} + +interface ColorPresentationParams { + textDocument: { uri: string }; + color: Color; + range: LspRange; +} + +const DEFAULT_DEBOUNCE_MS = 150; +const DEFAULT_MAX_COLORS = 500; +const DEFAULT_VIEWPORT_BUFFER = 30; + +const setColors = StateEffect.define(); + +/** LSP color ranges — used by regex colorView to skip covered spans. */ +export const lspDocumentColorsField = StateField.define({ + create: () => [], + update(colors, tr) { + let next = colors; + if (tr.docChanged && next.length) { + const mapped: LspDocumentColor[] = []; + for (const c of next) { + const from = tr.changes.mapPos(c.from, 1); + const to = tr.changes.mapPos(c.to, -1); + if (from < to) mapped.push({ ...c, from, to }); + } + next = mapped; + } + for (const e of tr.effects) { + if (e.is(setColors)) return e.value; + } + return next; + }, +}); + +function pickPresentation( + presentations: ColorPresentation[], + originalText: string, + pickedCss: string, +): ColorPresentation | null { + if (!presentations.length) return null; + + const origFmt = detectColorFormat(originalText); + const pickedFmt = detectColorFormat(pickedCss); + + const score = (p: ColorPresentation): number => { + const label = p.label || ""; + const fmt = detectColorFormat(label); + let s = 0; + if (fmt === origFmt) s += 4; + if (fmt === pickedFmt) s += 2; + if (p.textEdit) s += 1; + s += Math.max(0, 40 - label.length) / 100; + return s; + }; + + let best = presentations[0]; + let bestScore = score(best); + for (let i = 1; i < presentations.length; i++) { + const p = presentations[i]; + const s = score(p); + if (s > bestScore) { + best = p; + bestScore = s; + } + } + return best; +} + +export { hasLspColorProvider as hasColorProvider }; + +function viewportBounds( + view: EditorView, + bufferLines: number, +): { from: number; to: number } { + const doc = view.state.doc; + if (!doc.length) return { from: 0, to: 0 }; + const { from, to } = view.viewport; + const startLine = doc.lineAt(Math.max(0, from)); + const endLine = doc.lineAt(Math.min(doc.length, to)); + const fromLine = Math.max(1, startLine.number - bufferLines); + const toLine = Math.min(doc.lines, endLine.number + bufferLines); + return { + from: doc.line(fromLine).from, + to: doc.line(toLine).to, + }; +} + +function toChipPayload(c: LspDocumentColor): ColorChipPayload { + return { + from: c.from, + to: c.to, + css: c.css, + pickerSeed: lspColorToHex(c.color), + source: "lsp", + }; +} + +function buildDecos( + colors: readonly LspDocumentColor[], + bounds: { from: number; to: number }, + docLen: number, +): DecorationSet { + if (!colors.length || docLen <= 0) return Decoration.none; + + const decos: Range[] = []; + for (const c of colors) { + if (c.from < 0 || c.to > docLen || c.from >= c.to) continue; + if (c.to < bounds.from || c.from > bounds.to) continue; + decos.push(colorChipDecoration(toChipPayload(c))); + } + return Decoration.set(decos, true); +} + +function mapLspRange( + lsp: LSPPluginAPI, + range: LspRange, + docLen: number, +): { from: number; to: number } | null { + let from: number; + let to: number; + try { + const fromBase = lsp.fromPosition(range.start, lsp.syncedDoc); + const toBase = lsp.fromPosition(range.end, lsp.syncedDoc); + const fromMapped = lsp.unsyncedChanges.mapPos( + fromBase, + 1, + MapMode.TrackDel, + ); + const toMapped = lsp.unsyncedChanges.mapPos(toBase, -1, MapMode.TrackDel); + if (fromMapped == null || toMapped == null) return null; + from = fromMapped; + to = toMapped; + } catch { + return null; + } + if (from < 0 || to > docLen || from >= to) return null; + return { from, to }; +} + +function toLspRange(lsp: LSPPluginAPI, from: number, to: number): LspRange { + return { + start: lsp.toPosition(from), + end: lsp.toPosition(to), + }; +} + +function applyTextEdits( + lsp: LSPPluginAPI, + view: EditorView, + edits: TextEdit[], +): boolean { + const changes: { from: number; to: number; insert: string }[] = []; + for (const edit of edits) { + if (!edit?.range) continue; + const mapped = mapLspRange(lsp, edit.range, view.state.doc.length); + if (!mapped) continue; + const insert = + typeof edit.newText === "string" + ? edit.newText.replace(/\r\n/g, "\n") + : ""; + changes.push({ from: mapped.from, to: mapped.to, insert }); + } + if (!changes.length) return false; + changes.sort((a, b) => a.from - b.from || a.to - b.to); + view.dispatch({ changes }); + return true; +} + +function clamp01(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.min(1, Math.max(0, n)); +} + +function createPlugin(config: DocumentColorsConfig) { + const delay = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const maxColors = config.maxColors ?? DEFAULT_MAX_COLORS; + const bufferLines = config.viewportBufferLines ?? DEFAULT_VIEWPORT_BUFFER; + const showPicker = config.showPicker !== false; + + return ViewPlugin.fromClass( + class { + decorations: DecorationSet = Decoration.none; + timer: ReturnType | null = null; + reqId = 0; + providerKnown = false; + hasProvider = false; + connectRetries = 0; + + constructor(private view: EditorView) { + this.schedule(true); + } + + update(update: ViewUpdate): void { + const colors = update.state.field(lspDocumentColorsField, false) ?? []; + + if ( + update.viewportChanged || + update.docChanged || + update.transactions.some((t) => + t.effects.some((e) => e.is(setColors)), + ) + ) { + this.decorations = buildDecos( + colors, + viewportBounds(update.view, bufferLines), + update.state.doc.length, + ); + } + + if (update.docChanged) { + this.schedule(false); + } + } + + schedule(immediate: boolean): void { + if (this.timer) clearTimeout(this.timer); + if (immediate) { + this.timer = null; + void this.fetch(); + return; + } + this.timer = setTimeout(() => { + this.timer = null; + void this.fetch(); + }, delay); + } + + async fetch(): Promise { + if (appSettings.value?.colorPreview === false) { + this.clearColors(); + return; + } + + const lsp = LSPPlugin.get(this.view) as LSPPluginAPI | null; + if (!lsp?.client.connected) { + if (this.hasProvider || this.providerKnown) { + this.hasProvider = false; + this.providerKnown = false; + this.clearColors(); + } + if (this.connectRetries < 30) { + this.connectRetries++; + this.schedule(false); + } + return; + } + this.connectRetries = 0; + + const caps = lsp.client.serverCapabilities as + | { colorProvider?: boolean | object } + | null + | undefined; + + if (!caps) { + if (this.connectRetries < 30) { + this.connectRetries++; + this.schedule(false); + } + return; + } + + const provider = !!caps.colorProvider; + this.providerKnown = true; + if (!provider) { + if (this.hasProvider) { + this.hasProvider = false; + this.clearColors(); + } + return; + } + this.hasProvider = true; + + lsp.client.sync(); + const id = ++this.reqId; + const docLen = this.view.state.doc.length; + + try { + const result = await lsp.client.request< + DocumentColorParams, + ColorInformation[] | null + >("textDocument/documentColor", { + textDocument: { uri: lsp.uri }, + }); + + if (id !== this.reqId) return; + + const stored = this.process(lsp, result ?? [], docLen); + this.view.dispatch({ effects: setColors.of(stored) }); + } catch { + /* keep previous chips */ + } + } + + process( + lsp: LSPPluginAPI, + infos: ColorInformation[], + docLen: number, + ): LspDocumentColor[] { + const out: LspDocumentColor[] = []; + for (const info of infos) { + if (!info?.range || !info.color) continue; + const mapped = mapLspRange(lsp, info.range, docLen); + if (!mapped) continue; + + const color: Color = { + red: clamp01(info.color.red), + green: clamp01(info.color.green), + blue: clamp01(info.color.blue), + alpha: clamp01(info.color.alpha ?? 1), + }; + + out.push({ + from: mapped.from, + to: mapped.to, + color, + css: lspColorToCss(color), + }); + + if (out.length >= maxColors) break; + } + return out.sort((a, b) => a.from - b.from || a.to - b.to); + } + + clearColors(): void { + const current = + this.view.state.field(lspDocumentColorsField, false) ?? []; + if (current.length) { + this.view.dispatch({ effects: setColors.of([]) }); + } + } + + destroy(): void { + if (this.timer) clearTimeout(this.timer); + this.reqId++; + } + }, + { + decorations: (v) => v.decorations, + eventHandlers: { + click(e: MouseEvent, view: EditorView): boolean { + if (!showPicker) return false; + + const chip = findColorChip(e.target); + if (!chip) return false; + + const payload = getColorChipPayload(chip); + if (!payload || payload.source !== "lsp") return false; + if (!isViewEditable(view)) return true; + + void handleColorPick(view, payload); + return true; + }, + }, + }, + ); +} + +async function handleColorPick( + view: EditorView, + payload: ColorChipPayload, +): Promise { + const lsp = LSPPlugin.get(view) as LSPPluginAPI | null; + if (!lsp?.client.connected) return; + + const doc = view.state.doc; + const stillValid = + payload.from >= 0 && payload.to <= doc.length && payload.from < payload.to; + const currentText = stillValid + ? doc.sliceString(payload.from, payload.to) + : payload.css; + const seed = payload.pickerSeed || currentText || payload.css || "#ffffff"; + + const picked = await openColorPicker(seed); + if (!picked) return; + + const lsp2 = LSPPlugin.get(view) as LSPPluginAPI | null; + if (!lsp2?.client.connected) return; + + const live = findLiveColor(view, payload, currentText); + if (!live) return; + + const lspColor = cssToLspColor(picked); + if (!lspColor) { + view.dispatch({ + changes: { from: live.from, to: live.to, insert: picked }, + }); + return; + } + + try { + lsp2.client.sync(); + const range = toLspRange(lsp2, live.from, live.to); + const presentations = await lsp2.client.request< + ColorPresentationParams, + ColorPresentation[] | null + >("textDocument/colorPresentation", { + textDocument: { uri: lsp2.uri }, + color: lspColor, + range, + }); + + const chosen = pickPresentation(presentations ?? [], live.text, picked); + if (chosen?.textEdit) { + const edits: TextEdit[] = [chosen.textEdit]; + if (chosen.additionalTextEdits?.length) { + edits.push(...chosen.additionalTextEdits); + } + if (applyTextEdits(lsp2, view, edits)) return; + } + + const insert = chosen?.label || picked; + const after = findLiveColor(view, live, live.text); + if (!after) return; + view.dispatch({ + changes: { from: after.from, to: after.to, insert }, + }); + } catch { + const after = findLiveColor(view, live, live.text); + if (!after) return; + view.dispatch({ + changes: { from: after.from, to: after.to, insert: picked }, + }); + } +} + +function findLiveColor( + view: EditorView, + hint: { from: number; to: number }, + expectedText?: string, +): { from: number; to: number; text: string } | null { + const colors = view.state.field(lspDocumentColorsField, false) ?? []; + const doc = view.state.doc; + + const match = + colors.find((c) => c.from === hint.from && c.to === hint.to) ?? + colors.find((c) => c.from <= hint.to && c.to >= hint.from) ?? + null; + + const from = match?.from ?? hint.from; + const to = match?.to ?? hint.to; + if (from < 0 || to > doc.length || from >= to) return null; + + const text = doc.sliceString(from, to); + if (expectedText != null && text !== expectedText) return null; + return { from, to, text }; +} + +export function documentColorsClientExtension(): LSPClientExtension { + return { + clientCapabilities: { + textDocument: { + colorProvider: { + dynamicRegistration: true, + }, + }, + }, + }; +} + +export function documentColorsEditorExtension( + config: DocumentColorsConfig = {}, +): Extension { + if (config.enabled === false) return []; + return [lspDocumentColorsField, createPlugin(config), colorChipTheme]; +} + +export function documentColorsExtension( + config: DocumentColorsConfig = {}, +): LSPClientExtension & { editorExtension: Extension } { + return { + ...documentColorsClientExtension(), + editorExtension: documentColorsEditorExtension(config), + }; +} + +export { cssToLspColor, lspColorToCss, lspColorToHex }; + +export default documentColorsExtension; diff --git a/src/cm/lsp/index.ts b/src/cm/lsp/index.ts index 904c6c31c..eb5cb41b5 100644 --- a/src/cm/lsp/index.ts +++ b/src/cm/lsp/index.ts @@ -49,6 +49,20 @@ export { supportsDocumentSymbols, } from "./documentSymbols"; export { registerLspFormatter } from "./formatter"; +export type { + DocumentColorsConfig, + LspDocumentColor, +} from "./documentColors"; +export { + cssToLspColor, + documentColorsClientExtension, + documentColorsEditorExtension, + documentColorsExtension, + hasColorProvider, + lspColorToCss, + lspColorToHex, + lspDocumentColorsField, +} from "./documentColors"; export type { InlayHintsConfig } from "./inlayHints"; export { inlayHintsClientExtension, diff --git a/src/cm/lsp/types.ts b/src/cm/lsp/types.ts index 1ea75b204..1448056c7 100644 --- a/src/cm/lsp/types.ts +++ b/src/cm/lsp/types.ts @@ -248,6 +248,8 @@ export interface BuiltinExtensionsConfig { diagnostics?: boolean; inlayHints?: boolean; formatting?: boolean; + /** Document color chips via textDocument/documentColor (default true). */ + documentColors?: boolean; } export interface AcodeClientConfig { From ff5b63a7d9b907ca407a9b850071e5ab6abd2bdc Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:26:52 +0530 Subject: [PATCH 2/3] fix the reconnect issues --- src/cm/colorChip.ts | 2 +- src/cm/colorView.ts | 12 +++++------- src/cm/lsp/documentColors.ts | 25 ++++++++++++++++--------- src/cm/lsp/index.ts | 1 + 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/cm/colorChip.ts b/src/cm/colorChip.ts index cbaa99976..91f43133e 100644 --- a/src/cm/colorChip.ts +++ b/src/cm/colorChip.ts @@ -110,7 +110,7 @@ export async function openColorPicker(seed: string): Promise { } } -function clamp01(n: number): number { +export function clamp01(n: number): number { if (!Number.isFinite(n)) return 0; return Math.min(1, Math.max(0, n)); } diff --git a/src/cm/colorView.ts b/src/cm/colorView.ts index 9300f8ed2..33b58bc36 100644 --- a/src/cm/colorView.ts +++ b/src/cm/colorView.ts @@ -13,7 +13,10 @@ import { openColorPicker, type ColorChipPayload, } from "./colorChip"; -import { lspDocumentColorsField } from "./lsp/documentColors"; +import { + didReplaceLspDocumentColors, + lspDocumentColorsField, +} from "./lsp/documentColors"; interface DocRange { from: number; @@ -264,16 +267,11 @@ class ColorViewPlugin { } update(update: ViewUpdate): void { - const lspChanged = - update.startState.field(lspDocumentColorsField, false) !== - update.state.field(lspDocumentColorsField, false); - if (update.docChanged || update.viewportChanged || update.geometryChanged) { this.scheduleDecorations(update); } - // Rescan when LSP colors arrive/change so regex fills gaps / drops overlaps - if (lspChanged) { + if (didReplaceLspDocumentColors(update)) { this.pendingView = update.view; this.pendingDirtyRanges = normalizeRanges([ ...this.pendingDirtyRanges, diff --git a/src/cm/lsp/documentColors.ts b/src/cm/lsp/documentColors.ts index 32c6a508a..743085eb5 100644 --- a/src/cm/lsp/documentColors.ts +++ b/src/cm/lsp/documentColors.ts @@ -18,6 +18,7 @@ import type { TextEdit, } from "vscode-languageserver-types"; import { + clamp01, colorChipDecoration, colorChipTheme, cssToLspColor, @@ -219,9 +220,14 @@ function applyTextEdits( return true; } -function clamp01(n: number): number { - if (!Number.isFinite(n)) return 0; - return Math.min(1, Math.max(0, n)); +const MAX_CONNECT_RETRIES = 30; +const MAX_CAP_RETRIES = 30; + +/** True when a transaction replaced the LSP color list (not just mapped positions). */ +export function didReplaceLspDocumentColors(update: ViewUpdate): boolean { + return update.transactions.some((t) => + t.effects.some((e) => e.is(setColors)), + ); } function createPlugin(config: DocumentColorsConfig) { @@ -238,6 +244,7 @@ function createPlugin(config: DocumentColorsConfig) { providerKnown = false; hasProvider = false; connectRetries = 0; + capRetries = 0; constructor(private view: EditorView) { this.schedule(true); @@ -249,9 +256,7 @@ function createPlugin(config: DocumentColorsConfig) { if ( update.viewportChanged || update.docChanged || - update.transactions.some((t) => - t.effects.some((e) => e.is(setColors)), - ) + didReplaceLspDocumentColors(update) ) { this.decorations = buildDecos( colors, @@ -291,7 +296,8 @@ function createPlugin(config: DocumentColorsConfig) { this.providerKnown = false; this.clearColors(); } - if (this.connectRetries < 30) { + this.capRetries = 0; + if (this.connectRetries < MAX_CONNECT_RETRIES) { this.connectRetries++; this.schedule(false); } @@ -305,12 +311,13 @@ function createPlugin(config: DocumentColorsConfig) { | undefined; if (!caps) { - if (this.connectRetries < 30) { - this.connectRetries++; + if (this.capRetries < MAX_CAP_RETRIES) { + this.capRetries++; this.schedule(false); } return; } + this.capRetries = 0; const provider = !!caps.colorProvider; this.providerKnown = true; diff --git a/src/cm/lsp/index.ts b/src/cm/lsp/index.ts index eb5cb41b5..e3dfebaf6 100644 --- a/src/cm/lsp/index.ts +++ b/src/cm/lsp/index.ts @@ -55,6 +55,7 @@ export type { } from "./documentColors"; export { cssToLspColor, + didReplaceLspDocumentColors, documentColorsClientExtension, documentColorsEditorExtension, documentColorsExtension, From 08d4eeabaedd493c344c25652831d266d92e0dc2 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:21:03 +0530 Subject: [PATCH 3/3] handle edge case --- src/cm/lsp/documentColors.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cm/lsp/documentColors.ts b/src/cm/lsp/documentColors.ts index 743085eb5..3717acb68 100644 --- a/src/cm/lsp/documentColors.ts +++ b/src/cm/lsp/documentColors.ts @@ -332,7 +332,6 @@ function createPlugin(config: DocumentColorsConfig) { lsp.client.sync(); const id = ++this.reqId; - const docLen = this.view.state.doc.length; try { const result = await lsp.client.request< @@ -344,7 +343,11 @@ function createPlugin(config: DocumentColorsConfig) { if (id !== this.reqId) return; - const stored = this.process(lsp, result ?? [], docLen); + const stored = this.process( + lsp, + result ?? [], + this.view.state.doc.length, + ); this.view.dispatch({ effects: setColors.of(stored) }); } catch { /* keep previous chips */