mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}` : ""})`;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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}'",
|
||||
|
||||
Reference in New Issue
Block a user