|
| 1 | +import { LSPPlugin } from "@codemirror/lsp-client"; |
| 2 | +import type { Range } from "@codemirror/state"; |
| 3 | +import { Decoration, EditorView, WidgetType } from "@codemirror/view"; |
| 4 | +import pickColor from "dialogs/color"; |
| 5 | +import color from "utils/color"; |
| 6 | +import type { Color as LspColor } from "vscode-languageserver-types"; |
| 7 | + |
| 8 | +export const COLOR_CHIP_CLASS = "cm-color-chip"; |
| 9 | +export const COLOR_CHIP_SOURCE_ATTR = "data-color-source"; |
| 10 | + |
| 11 | +export type ColorChipSource = "regex" | "lsp"; |
| 12 | + |
| 13 | +export interface ColorChipPayload { |
| 14 | + from: number; |
| 15 | + to: number; |
| 16 | + css: string; |
| 17 | + pickerSeed?: string; |
| 18 | + source: ColorChipSource; |
| 19 | +} |
| 20 | + |
| 21 | +const chipState = new WeakMap<HTMLElement, ColorChipPayload>(); |
| 22 | + |
| 23 | +export function getColorChipPayload( |
| 24 | + el: HTMLElement | null | undefined, |
| 25 | +): ColorChipPayload | undefined { |
| 26 | + if (!el) return undefined; |
| 27 | + return chipState.get(el); |
| 28 | +} |
| 29 | + |
| 30 | +export function findColorChip(target: EventTarget | null): HTMLElement | null { |
| 31 | + if (!(target instanceof Element)) return null; |
| 32 | + return target.closest(`.${COLOR_CHIP_CLASS}`) as HTMLElement | null; |
| 33 | +} |
| 34 | + |
| 35 | +export class ColorChipWidget extends WidgetType { |
| 36 | + constructor(readonly payload: ColorChipPayload) { |
| 37 | + super(); |
| 38 | + } |
| 39 | + |
| 40 | + eq(other: ColorChipWidget): boolean { |
| 41 | + return ( |
| 42 | + other.payload.from === this.payload.from && |
| 43 | + other.payload.to === this.payload.to && |
| 44 | + other.payload.css === this.payload.css && |
| 45 | + other.payload.source === this.payload.source && |
| 46 | + (other.payload.pickerSeed || "") === (this.payload.pickerSeed || "") |
| 47 | + ); |
| 48 | + } |
| 49 | + |
| 50 | + toDOM(): HTMLElement { |
| 51 | + const el = document.createElement("span"); |
| 52 | + el.className = COLOR_CHIP_CLASS; |
| 53 | + el.setAttribute(COLOR_CHIP_SOURCE_ATTR, this.payload.source); |
| 54 | + el.style.display = "inline-block"; |
| 55 | + el.style.width = "0.9em"; |
| 56 | + el.style.height = "0.9em"; |
| 57 | + el.style.borderRadius = "2px"; |
| 58 | + el.style.verticalAlign = "middle"; |
| 59 | + el.style.margin = "0 2px"; |
| 60 | + el.style.boxSizing = "border-box"; |
| 61 | + el.style.border = "1px solid rgba(0,0,0,0.2)"; |
| 62 | + el.style.backgroundColor = this.payload.css; |
| 63 | + el.style.cursor = "pointer"; |
| 64 | + el.style.userSelect = "none"; |
| 65 | + el.title = this.payload.css; |
| 66 | + el.dataset["color"] = this.payload.pickerSeed || this.payload.css; |
| 67 | + el.dataset["colorraw"] = this.payload.css; |
| 68 | + chipState.set(el, this.payload); |
| 69 | + return el; |
| 70 | + } |
| 71 | + |
| 72 | + ignoreEvent(): boolean { |
| 73 | + return false; |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +export function colorChipDecoration( |
| 78 | + payload: ColorChipPayload, |
| 79 | +): Range<Decoration> { |
| 80 | + return Decoration.widget({ |
| 81 | + widget: new ColorChipWidget(payload), |
| 82 | + side: -1, |
| 83 | + }).range(payload.from); |
| 84 | +} |
| 85 | + |
| 86 | +export function isViewEditable(view: EditorView): boolean { |
| 87 | + const readOnly = view.contentDOM.ariaReadOnly === "true"; |
| 88 | + const editable = view.contentDOM.contentEditable === "true"; |
| 89 | + return !readOnly && editable; |
| 90 | +} |
| 91 | + |
| 92 | +export function hasLspColorProvider(view: EditorView): boolean { |
| 93 | + const lsp = LSPPlugin.get(view) as { |
| 94 | + client?: { |
| 95 | + connected?: boolean; |
| 96 | + serverCapabilities?: { colorProvider?: boolean | object } | null; |
| 97 | + }; |
| 98 | + } | null; |
| 99 | + return !!( |
| 100 | + lsp?.client?.connected && lsp.client.serverCapabilities?.colorProvider |
| 101 | + ); |
| 102 | +} |
| 103 | + |
| 104 | +export async function openColorPicker(seed: string): Promise<string | null> { |
| 105 | + try { |
| 106 | + const picked = await pickColor(seed || "#ffffff"); |
| 107 | + return picked || null; |
| 108 | + } catch { |
| 109 | + return null; |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +export function clamp01(n: number): number { |
| 114 | + if (!Number.isFinite(n)) return 0; |
| 115 | + return Math.min(1, Math.max(0, n)); |
| 116 | +} |
| 117 | + |
| 118 | +function channelToByte(n: number): number { |
| 119 | + return Math.round(clamp01(n) * 255); |
| 120 | +} |
| 121 | + |
| 122 | +export function lspColorToCss(c: LspColor): string { |
| 123 | + const r = channelToByte(c.red); |
| 124 | + const g = channelToByte(c.green); |
| 125 | + const b = channelToByte(c.blue); |
| 126 | + const a = clamp01(c.alpha ?? 1); |
| 127 | + if (a < 1) { |
| 128 | + const alpha = Number(a.toFixed(3)); |
| 129 | + return `rgba(${r}, ${g}, ${b}, ${alpha})`; |
| 130 | + } |
| 131 | + return `rgb(${r}, ${g}, ${b})`; |
| 132 | +} |
| 133 | + |
| 134 | +export function lspColorToHex(c: LspColor): string { |
| 135 | + const r = channelToByte(c.red).toString(16).padStart(2, "0"); |
| 136 | + const g = channelToByte(c.green).toString(16).padStart(2, "0"); |
| 137 | + const b = channelToByte(c.blue).toString(16).padStart(2, "0"); |
| 138 | + const a = clamp01(c.alpha ?? 1); |
| 139 | + if (a < 1) { |
| 140 | + const aa = channelToByte(a).toString(16).padStart(2, "0"); |
| 141 | + return `#${r}${g}${b}${aa}`; |
| 142 | + } |
| 143 | + return `#${r}${g}${b}`; |
| 144 | +} |
| 145 | + |
| 146 | +export function cssToLspColor(css: string): LspColor | null { |
| 147 | + if (!css || typeof css !== "string") return null; |
| 148 | + try { |
| 149 | + const { r, g, b, a } = color(css.trim()).rgb; |
| 150 | + return { |
| 151 | + red: clamp01(r / 255), |
| 152 | + green: clamp01(g / 255), |
| 153 | + blue: clamp01(b / 255), |
| 154 | + alpha: clamp01(a), |
| 155 | + }; |
| 156 | + } catch { |
| 157 | + return null; |
| 158 | + } |
| 159 | +} |
| 160 | + |
| 161 | +export function detectColorFormat( |
| 162 | + text: string, |
| 163 | +): "hex" | "rgb" | "hsl" | "other" { |
| 164 | + const t = text.trim().toLowerCase(); |
| 165 | + if (t.startsWith("#")) return "hex"; |
| 166 | + if (t.startsWith("rgb")) return "rgb"; |
| 167 | + if (t.startsWith("hsl")) return "hsl"; |
| 168 | + return "other"; |
| 169 | +} |
| 170 | + |
| 171 | +export const colorChipTheme = EditorView.baseTheme({ |
| 172 | + [`.${COLOR_CHIP_CLASS}`]: { |
| 173 | + userSelect: "none", |
| 174 | + }, |
| 175 | +}); |
0 commit comments