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('