feat(editor): color preview swatches in the gutter (#1341)

Lines with a color literal show a swatch of the (first) color in the
gutter: `#rgb` / `#rrggbb` with or without alpha, `rgb()` / `rgba()` /
`hsl()` / `hsla()`, and CSS named colors in property values of style
sheets. Outside style sheets and markup only quoted hex values and color
functions count, so `#123` issue numbers and `#add` comments get none;
prose files get none at all.

Clicking a swatch in an editable file opens the system color picker; the
picked color replaces the literal in its own format (hex case and length,
the function's name and separators, the original alpha), as one undoable
edit per pick. Settings › Editor › General › Gutter Icons › Color preview
turns the swatches off.

Fixes #1206
This commit is contained in:
l0ng-ai
2026-11-05 02:10:42 +08:00
committed by GitHub
parent ce898ea0c6
commit 2013ddaac2
10 changed files with 383 additions and 3 deletions
+1
View File
@@ -432,6 +432,7 @@ export const GUTTER_ICON_GROUPS = [
{ id: "bookmarks", gutterClass: "cm-openidea-bookmarks" },
{ id: "inheritors", gutterClass: "cm-openidea-gutter-lineMarkers" },
{ id: "supers", gutterClass: "cm-openidea-gutter-lineMarkers" },
{ id: "colors", gutterClass: "cm-openidea-gutter-colors" },
] as const;
export interface PrefsOptions {
+4 -3
View File
@@ -104,9 +104,10 @@ export const openideaTheme = EditorView.theme({
"&.cm-openidea-methodSeparators .cm-openidea-methodSeparator": {
boxShadow: "inset 0 1px 0 var(--border-method-separator, var(--border))",
},
"&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks": {
display: "none !important",
},
"&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks, &.cm-openidea-hideGutter-colors .cm-openidea-gutter-colors":
{
display: "none !important",
},
// Inheritance markers share one gutter: each group hides its own icons.
"&.cm-openidea-hideGutter-inheritors :is(.cm-openidea-lineMarker-implemented, .cm-openidea-lineMarker-overridden), &.cm-openidea-hideGutter-supers :is(.cm-openidea-lineMarker-implementing, .cm-openidea-lineMarker-overriding)":
{ display: "none" },
@@ -0,0 +1,35 @@
/* Editor gutter color swatches. */
.gutter :global(.cm-gutterElement) {
display: flex;
align-items: center;
justify-content: center;
}
.swatch {
width: 12px;
height: 12px;
box-sizing: border-box;
border: 1px solid var(--border-strong);
border-radius: var(--radius-small);
overflow: hidden;
}
.swatch > span {
display: block;
width: 100%;
height: 100%;
}
.editable {
cursor: pointer;
}
.editable:hover {
border-color: var(--fg-muted);
}
/* The native color input only anchors the system picker. */
.picker {
position: fixed;
width: 1px;
height: 1px;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { findColors, formatColor, fromHex6, parseComputed, toHex6, type ColorMatch } from "./colors";
const NAMES = new Set(["red", "rebeccapurple", "white"]);
const isName = (w: string) => NAMES.has(w.toLowerCase());
const texts = (line: string, language: string) => findColors(line, language, isName).map((c) => c.text);
describe("findColors", () => {
it("finds every format in style sheets", () => {
expect(texts(" background-color: #1b766c;", "css")).toEqual(["#1b766c"]);
expect(texts(" border: 1px solid #FFF8;", "scss")).toEqual(["#FFF8"]);
expect(texts(" color: rgba(0, 0, 0, .5); fill: hsl(210deg 40% 50% / 0.3);", "css")).toEqual([
"rgba(0, 0, 0, .5)",
"hsl(210deg 40% 50% / 0.3)",
]);
expect(texts(" color: red; outline: 2px solid RebeccaPurple;", "less")).toEqual(["red", "RebeccaPurple"]);
expect(findColors("a { color: #abc }", "css", isName)[0]).toMatchObject({ from: 11, to: 15, format: "hex" });
});
it("skips names that are no property value, keywords and non-colors", () => {
expect(texts(".red, #main { white-space: nowrap; color: inherit }", "css")).toEqual([]);
expect(texts("@media screen { }", "css")).toEqual([]);
expect(texts(" color: #12345;", "css")).toEqual([]);
});
it("takes only quoted hex values in code, and no names", () => {
expect(texts(`const accent = "#1b766c"; // see #123 and #add`, "typescript")).toEqual(["#1b766c"]);
expect(texts("color = 'rgb(255, 0, 0)' # red", "python")).toEqual(["rgb(255, 0, 0)"]);
expect(texts('<div style="color: #fff">', "html")).toEqual(["#fff"]);
});
it("leaves prose alone", () => {
expect(texts("Fixed in #123, color `#fff`", "markdown")).toEqual([]);
});
});
describe("formatColor", () => {
const match = (text: string, format: ColorMatch["format"]): ColorMatch => ({
from: 0,
to: text.length,
text,
format,
});
it("keeps hex case, length and alpha", () => {
expect(formatColor([255, 0, 0, 1], match("#abc", "hex"))).toBe("#f00");
expect(formatColor([18, 52, 86, 1], match("#ABC", "hex"))).toBe("#123456");
expect(formatColor([18, 52, 86, 1], match("#1B766C", "hex"))).toBe("#123456".toUpperCase());
expect(formatColor([18, 52, 86, 0.5], match("#1b766c80", "hex"))).toBe("#12345680");
expect(formatColor([18, 52, 86, 1], match("red", "name"))).toBe("#123456");
});
it("keeps a function's name and separators", () => {
expect(formatColor([1, 2, 3, 1], match("rgb(0, 0, 0)", "rgb"))).toBe("rgb(1, 2, 3)");
expect(formatColor([1, 2, 3, 0.5], match("rgba(0, 0, 0, .5)", "rgb"))).toBe("rgba(1, 2, 3, 0.5)");
expect(formatColor([1, 2, 3, 1], match("rgb(0 0 0 / 50%)", "rgb"))).toBe("rgb(1 2 3 / 1)");
expect(formatColor([255, 0, 0, 1], match("hsl(0, 0%, 0%)", "hsl"))).toBe("hsl(0, 100%, 50%)");
});
});
describe("conversions", () => {
it("round-trips the color input's hex and reads computed colors", () => {
expect(toHex6([27, 118, 108, 1])).toBe("#1b766c");
expect(fromHex6("#1b766c", 0.5)).toEqual([27, 118, 108, 0.5]);
expect(parseComputed("rgb(27, 118, 108)")).toEqual([27, 118, 108, 1]);
expect(parseComputed("rgba(0, 0, 0, 0.25)")).toEqual([0, 0, 0, 0.25]);
expect(parseComputed("color(display-p3 1 0 0)")).toBeNull();
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Color literals of a line for the gutter's color preview: `#rgb` / `#rrggbb` (with alpha),
* `rgb()` / `rgba()` / `hsl()` / `hsla()`, and CSS named colors in style sheets. Pure; the
* names are checked by the caller (the browser knows them).
*/
export type ColorFormat = "hex" | "rgb" | "hsl" | "name";
export interface ColorMatch {
/** UTF-16 offsets in the line. */
from: number;
to: number;
text: string;
format: ColorFormat;
}
/** Red, green, blue 0–255 and alpha 0–1. */
export type Rgba = [number, number, number, number];
/** Style sheet languages: named colors and unquoted hex values are colors there. */
const STYLE_SHEETS = new Set(["css", "scss", "sass", "less", "stylus", "postcss"]);
/** Markup that embeds style sheets (`<style>`, `style="…"`): unquoted hex values too. */
const MARKUP = new Set(["html", "vue", "svelte", "astro", "xml", "xhtml"]);
/** Prose: `#123` is an issue, `#fab` a heading or a tag. */
const PROSE = new Set(["markdown", "plaintext", "text", "restructuredtext", "asciidoc", "git-commit"]);
const HEX = String.raw`#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])`;
const NUMBER = String.raw`[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?`;
const FUNCTION = String.raw`\b(?:rgba?|hsla?)\(\s*${NUMBER}(?:deg|%)?(?:\s*[,\s]\s*${NUMBER}%?){2}(?:\s*[,/]\s*${NUMBER}%?)?\s*\)`;
const COLOR_RE = new RegExp(`${FUNCTION}|${HEX}|(?<![\\w.#$@-])[a-zA-Z]{3,20}(?![\\w(-])`, "g");
/** Global keywords and words that are valid colors but say nothing about one. */
const NOT_COLORS = new Set(["inherit", "initial", "unset", "revert", "currentcolor", "transparent"]);
/**
* The colors on `line` of a document in `language`. Outside style sheets and markup, only quoted hex
* values (`"#1b766c"`) and color functions count: `#add` may be a comment, `#123` an issue.
*/
export function findColors(line: string, language: string, isColorName: (word: string) => boolean): ColorMatch[] {
if (PROSE.has(language)) return [];
const sheet = STYLE_SHEETS.has(language);
const looseHex = sheet || MARKUP.has(language);
const out: ColorMatch[] = [];
for (const m of line.matchAll(COLOR_RE)) {
const text = m[0];
const from = m.index;
const format: ColorFormat = text.startsWith("#")
? "hex"
: text.includes("(")
? /^r/i.test(text)
? "rgb"
: "hsl"
: "name";
if (format === "name") {
// A property value (`color: red`), not a property, selector or at-rule.
if (!sheet || NOT_COLORS.has(text.toLowerCase()) || !/:\s*(?:\S+\s+)*$/.test(line.slice(0, from))) continue;
if (!isColorName(text)) continue;
} else if (format === "hex" && !looseHex && !/["'`]$/.test(line.slice(0, from))) continue;
out.push({ from, to: from + text.length, text, format });
}
return out;
}
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const hex2 = (v: number) =>
Math.round(clamp(v, 0, 255))
.toString(16)
.padStart(2, "0");
const round = (v: number, digits = 2) => String(Number(v.toFixed(digits)));
/** `#rrggbb` of `rgba`, as `<input type=color>` takes it. */
export function toHex6([r, g, b]: Rgba): string {
return `#${hex2(r)}${hex2(g)}${hex2(b)}`;
}
/** `#rrggbb` → its components with `alpha`. */
export function fromHex6(hex: string, alpha: number): Rgba | null {
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
return m ? [Number.parseInt(m[1]!, 16), Number.parseInt(m[2]!, 16), Number.parseInt(m[3]!, 16), alpha] : null;
}
/** Components of the browser's computed color (`rgb(1, 2, 3)` / `rgba(1, 2, 3, 0.5)`), null for other spaces. */
export function parseComputed(color: string): Rgba | null {
const m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(color.trim());
if (!m) return null;
const a = m[4] === undefined ? 1 : m[4].endsWith("%") ? Number.parseFloat(m[4]) / 100 : Number(m[4]);
return [Number(m[1]), Number(m[2]), Number(m[3]), a];
}
function toHsl([r, g, b]: Rgba): [number, number, number] {
const [R, G, B] = [r / 255, g / 255, b / 255];
const max = Math.max(R, G, B);
const min = Math.min(R, G, B);
const l = (max + min) / 2;
const d = max - min;
if (d === 0) return [0, 0, l * 100];
const s = d / (1 - Math.abs(2 * l - 1));
const h = max === R ? ((G - B) / d) % 6 : max === G ? (B - R) / d + 2 : (R - G) / d + 4;
return [(h * 60 + 360) % 360, s * 100, l * 100];
}
/**
* `rgba` written in the original's format: hex keeps its case and whether it had alpha, a function
* keeps its name and separators; a named color becomes hex.
*/
export function formatColor(rgba: Rgba, original: ColorMatch): string {
const alpha = rgba[3];
const hasAlpha = alpha < 1;
if (original.format === "hex" || original.format === "name") {
const short = original.text.length === 4 || original.text.length === 5;
const hadAlpha = original.format === "hex" && (original.text.length === 5 || original.text.length === 9);
let hex = toHex6(rgba) + (hadAlpha || hasAlpha ? hex2(alpha * 255) : "");
// `#abc` stays short when the new color has a short form.
if (short && /^#(?:([0-9a-f])\1)+$/.test(hex)) hex = `#${hex.slice(1).replace(/(.)\1/g, "$1")}`;
return /[A-F]/.test(original.text) ? hex.toUpperCase() : hex;
}
const name = /^\w+/.exec(original.text)![0];
const commas = original.text.includes(",");
// The original's fourth component is its alpha.
const withAlpha = hasAlpha || (original.text.match(new RegExp(NUMBER, "g"))?.length ?? 0) > 3;
const parts =
original.format === "rgb"
? rgba.slice(0, 3).map((v) => String(Math.round(clamp(v, 0, 255))))
: toHsl(rgba).map((v, i) => (i === 0 ? round(v, 0) : `${round(v, 1)}%`));
const a = round(alpha);
if (commas) return `${name}(${parts.join(", ")}${withAlpha ? `, ${a}` : ""})`;
return `${name}(${parts.join(" ")}${withAlpha ? ` / ${a}` : ""})`;
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Color preview gutter: a swatch on each line with a color literal (the first one), as IDEA's
* color gutter icons. Clicking it in an editable file opens the system color picker; the picked
* color replaces the literal in its own format (alpha kept, the picker has none).
*/
import type { Extension } from "@codemirror/state";
import { GutterMarker, gutter, type EditorView } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import { findColors, formatColor, fromHex6, parseComputed, toHex6, type ColorMatch, type Rgba } from "./colors";
import styles from "./ColorPreview.module.css";
/** Longer lines (minified style sheets) are not scanned. */
const MAX_LINE = 2000;
const names = new Map<string, boolean>();
function isColorName(word: string): boolean {
let known = names.get(word);
if (known === undefined) {
known = typeof CSS !== "undefined" && CSS.supports("color", word);
names.set(word, known);
}
return known;
}
let probe: HTMLElement | null = null;
/** The literal's components, as the browser parses it. */
function rgbaOf(text: string): Rgba | null {
if (!probe?.isConnected) {
probe = document.createElement("span");
probe.hidden = true;
document.body.append(probe);
}
probe.style.color = "";
probe.style.color = text;
return probe.style.color ? parseComputed(getComputedStyle(probe).color) : null;
}
class Swatch extends GutterMarker {
constructor(
readonly color: ColorMatch,
readonly editable: boolean,
) {
super();
}
override eq(other: GutterMarker): boolean {
return (
other instanceof Swatch &&
other.color.text === this.color.text &&
other.color.from === this.color.from &&
other.editable === this.editable
);
}
override toDOM() {
const el = document.createElement("span");
el.className = this.editable ? `${styles.swatch} ${styles.editable}` : `${styles.swatch}`;
el.title = this.color.text;
const fill = document.createElement("span");
fill.style.background = this.color.text;
el.append(fill);
return el;
}
}
function lineColor(view: EditorView, pos: number, language: string): ColorMatch | null {
const line = view.state.doc.lineAt(pos);
if (line.length > MAX_LINE) return null;
return findColors(line.text, language, isColorName)[0] ?? null;
}
let picker: HTMLInputElement | null = null;
/** Opens the system color picker for `color` at `from`; each pick replaces the literal. */
function pickColor(view: EditorView, from: number, color: ColorMatch, at: { x: number; y: number }) {
const rgba = rgbaOf(color.text);
if (!rgba) return;
picker?.remove();
const input = document.createElement("input");
picker = input;
input.type = "color";
input.className = `${styles.picker}`;
input.style.left = `${at.x}px`;
input.style.top = `${at.y}px`;
input.value = toHex6(rgba);
document.body.append(input);
let current = color.text;
const apply = () => {
const next = fromHex6(input.value, rgba[3]);
// The literal changed meanwhile: leave it alone.
if (!next || view.state.sliceDoc(from, from + current.length) !== current) return;
const text = formatColor(next, color);
if (text === current) return;
view.dispatch({ changes: { from, to: from + current.length, insert: text }, userEvent: "input.color" });
current = text;
};
input.addEventListener("input", apply);
input.addEventListener("change", () => {
apply();
input.remove();
});
try {
input.showPicker();
} catch {
input.click();
}
}
export function colorPreviewGutter(ctx: EditorDocContext): Extension {
return gutter({
class: `${styles.gutter} cm-openidea-gutter-colors`,
lineMarker: (view, block) => {
const color = lineColor(view, block.from, ctx.language);
return color ? new Swatch(color, !view.state.readOnly) : null;
},
lineMarkerChange: (update) => update.docChanged || update.startState.readOnly !== update.state.readOnly,
domEventHandlers: {
mousedown: (view, block, event) => {
const e = event as MouseEvent;
const swatch = e.target instanceof Element ? e.target.closest(`.${styles.swatch}`) : null;
if (e.button !== 0 || !swatch || view.state.readOnly) return false;
const line = view.state.doc.lineAt(block.from);
const color = lineColor(view, block.from, ctx.language);
if (!color) return false;
e.preventDefault();
const rect = swatch.getBoundingClientRect();
pickColor(view, line.from + color.from, color, { x: rect.left, y: rect.bottom + 2 });
return true;
},
},
});
}
+7
View File
@@ -0,0 +1,7 @@
/** Color swatches in the editor gutter for color literals (Settings › Editor › General › Gutter Icons). */
import { registerEditorContribution } from "@/editor/api";
import { colorPreviewGutter } from "./gutter";
export function activate(): () => void {
return registerEditorContribution({ id: "colorPreview.gutter", order: 59, extensions: colorPreviewGutter });
}
+2
View File
@@ -24,6 +24,7 @@ import { activate as runAnything } from "./run-anything";
import { activate as macros } from "./macros";
import { activate as debuggerFeature } from "./debugger";
import { activate as coverage } from "./coverage";
import { activate as colorPreview } from "./color-preview";
import { activate as profiler } from "./profiler";
import { activate as buildTools } from "./build-tools";
import { activate as build } from "./build";
@@ -70,6 +71,7 @@ const features: Array<[string, Activate]> = [
["macros", macros],
["debugger", debuggerFeature],
["coverage", coverage],
["color-preview", colorPreview],
["profiler", profiler],
["build-tools", buildTools],
["build", build],
@@ -597,6 +597,12 @@ const GUTTER = editorPage({
keywords: ["super method", "overrides", "implements"],
component: () => <GutterGroup id="supers" label="Implementing and overriding methods" />,
},
{
t: "custom",
label: "Color preview",
keywords: ["color", "swatch", "colour"],
component: () => <GutterGroup id="colors" label="Color preview" />,
},
],
},
],
+1
View File
@@ -3983,6 +3983,7 @@
"Implemented and overridden": "已实现和已重写",
"Implementing and overriding": "实现和重写",
"Implemented and overridden declarations": "已被实现和重写的声明",
"Color preview": "颜色预览",
"Implementing and overriding methods": "实现和重写超类型方法的方法",
"Replace all {count} occurrences": "替换全部 {count} 处",
"↵ Apply · Esc Keep '{name}'": "↵ 应用 · Esc 保留 '{name}'",