From 2013ddaac299f74cbf4aaa483d58185b0d6b068c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:10:42 +0800 Subject: [PATCH] feat(editor): color preview swatches in the gutter (#1341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/editor/core/cm/prefs.ts | 1 + src/editor/core/cm/theme.ts | 7 +- .../color-preview/ColorPreview.module.css | 35 +++++ src/features/color-preview/colors.test.ts | 69 ++++++++++ src/features/color-preview/colors.ts | 128 +++++++++++++++++ src/features/color-preview/gutter.ts | 130 ++++++++++++++++++ src/features/color-preview/index.ts | 7 + src/features/index.ts | 2 + .../settings/pages/editor/general.tsx | 6 + src/platform/locales/text/zh.json | 1 + 10 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 src/features/color-preview/ColorPreview.module.css create mode 100644 src/features/color-preview/colors.test.ts create mode 100644 src/features/color-preview/colors.ts create mode 100644 src/features/color-preview/gutter.ts create mode 100644 src/features/color-preview/index.ts diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 99aa6507..333a054b 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -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 { diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 49641a97..8bb129fa 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -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" }, diff --git a/src/features/color-preview/ColorPreview.module.css b/src/features/color-preview/ColorPreview.module.css new file mode 100644 index 00000000..a3d0df89 --- /dev/null +++ b/src/features/color-preview/ColorPreview.module.css @@ -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; +} diff --git a/src/features/color-preview/colors.test.ts b/src/features/color-preview/colors.test.ts new file mode 100644 index 00000000..3cca997b --- /dev/null +++ b/src/features/color-preview/colors.test.ts @@ -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('
', "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(); + }); +}); diff --git a/src/features/color-preview/colors.ts b/src/features/color-preview/colors.ts new file mode 100644 index 00000000..0011c5a6 --- /dev/null +++ b/src/features/color-preview/colors.ts @@ -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 (`